In this blog post, I share my insights on writing readable and maintainable Azure Bicep templates. After several years of working with Azure Bicep templates, I have learned that as with any other programming language, clarity and readability are key for maintainability.
This blog post includes the following insights:
- Choose a consistent code and naming convention
- Define a clear template structure and use formatting
- Avoid untyped objects or arrays
- Avoid complex operations within definitions
Choose a consistent code and naming convention
If you have been programming, you might have adapted your code to a naming convention. For example, in C#, methods are written in PascalCase and variables are written in camelCase. The same principles can be applied to Bicep templates. I believe that having a naming convention for your Bicep template is really important, as this will greatly improve the readability of your Infrastructure-as-Code.
Currently, as far as I know, there is no naming convention āmarketā standard for Bicep (yet) like you see for PowerShell or C#.
Notation
If we look back at the Azure Resource Manager (ARM) templates in the JSON format, it was required to use [parameter(...)], [variable(...)] and so on. This looks similar to the Hungarian Notation. This notation adds a prefix to a parameter or variable. In Bicep, this would look something like parKeyVaultName (for a parameter) or varFormattedSubnet (for a variable). Essentially, it creates a clear distinction between where the value comes from.
I have worked with large Bicep templates (over 1,000 lines), and having a clear distinction between modules, resources, parameters, variables, outputs, functions, and types has saved me a lot of time. Letās look at two examples. In the templates below, you see the definition of Azure Key Vault with and without the Hungarian notation. Keep in mind that these examples are very small and serve only as illustrations; imagine applying this to a larger, more complex template.
Without Hungarian notation
In the snippet below, you see the version without the Hungarian notation. In this snippet itās difficult to distinguish where each value originates from and if you look at the output value keyVault it is unclear if itās either a resource, variable or a parameter holding key vault properties.
| param name string | |
| param tenantId string = tenant().tenantId | |
| var location = 'westeurope' | |
| var sku = 'standard' | |
| resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { | |
| name: name | |
| location: location | |
| properties: { | |
| sku: { | |
| name: sku | |
| family: 'A' | |
| } | |
| tenantId: tenantId | |
| } | |
| } | |
| output keyVault object = keyVault |
With Hungarian notation
In the snippet below, the Hungarian notation is applied. At a glance, it is clear where values are coming from, and the output clearly indicates that it uses the output of the deployed resource.
| param parName string | |
| param parTenantId string = tenant().tenantId | |
| var varLocation = 'westeurope' | |
| var varSku = 'standard' | |
| resource resKeyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { | |
| name: parName | |
| location: varLocation | |
| properties: { | |
| sku: { | |
| name: varSku | |
| family: 'A' | |
| } | |
| tenantId: parTenantId | |
| } | |
| } | |
| output outKeyVault object = resKeyVault |
If you are interested in using the Hungarian notation, then I recommend the following prefixes for the different components:
| Component | Prefix |
|---|---|
| Module | mod |
| Resource | res |
| Variable | var |
| Parameter | par |
| Output | out |
| Function | func |
| Type | type |
Note! This is just one of many ways Bicep templates can be written. If you or your team already have a naming convention that works for you, thatās perfectly fine too!
Define a clear template structure and use formatting
Bicep is a declarative language, which means that any component (resource, module, type etc.) can be defined at any place in the template. Also, it does not matter in which order the components are placed.
To avoid chaos, I keep a strict structure for my templates. This makes the templates predictable, easy to navigate and in the end easier to read. Additionally, having a formatted Bicep template enhances navigation and readability. For this, I use the built-in formatter in the Bicep extension which is available for Visual Studio Code.
Below is the structure I like to use:
| //////////// | |
| // Imports | |
| //////////// | |
| //////////// | |
| // metadata | |
| //////////// | |
| //////////// | |
| // targetScope (optional) | |
| //////////// | |
| //////////// | |
| // Parameters | |
| //////////// | |
| //////////// | |
| // Variables | |
| //////////// | |
| //////////// | |
| // Resources / Modules | |
| //////////// | |
| //////////// | |
| // Outputs | |
| //////////// | |
| //////////// | |
| // Custom Types | |
| //////////// | |
| //////////// | |
| // Functions | |
| //////////// |
Avoid untyped objects or arrays
It is not bad to have untyped objects or arrays; before the introduction of User-Defined Types we didnāt even know better. However, since this feature has been released, I have been a big fan of typing every object or array within my templates. By giving these a type, you give a meaning or a value to objects or arrays.
Knowing what an object or array contains enhances the readability of your Bicep templates greatly, and additionally, typing helps you to enforce what an object or array can or must contain. As an added advantage, you can also make use of IntelliSense for auto-completion in your Bicep template.
Below, you see a Bicep snippet where a virtual network is defined. This virtual network contains a parameterised subnets property called parSubnets and this parameter holds the type array. The downside here is that parSubnets can contain anything you want, but it requires specific values in order to successfully deploy the subnets.
| param parSubnets array | |
| resource resVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { | |
| name: 'virtualNetworkName' | |
| location: 'westeurope' | |
| properties: { | |
| addressSpace: { | |
| addressPrefixes: [ | |
| '192.168.1.0/24' | |
| ] | |
| } | |
| subnets: parSubnets | |
| } | |
| } |
Below is the same snippet, but now including the User-Defined Type. It shows in the Bicep template what is expected in the āarray of objectsā namely an object including the properties name and addressPrefix and there is an optional property named delegation due to the addition of ? behind the type.
| param parSubnets subnetType | |
| resource resVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { | |
| name: 'virtualNetworkName' | |
| location: 'westeurope' | |
| properties: { | |
| addressSpace: { | |
| addressPrefixes: [ | |
| '192.168.1.0/24' | |
| ] | |
| } | |
| subnets: parSubnets | |
| } | |
| } | |
| type subnetType = { | |
| name: string | |
| properties: { | |
| addressPrefix: string | |
| delegation: string? | |
| } | |
| }[] |
An added advantage is that it lets you hover over the parameter to see what it must, and can contain including IntelliSense support:


Besides User-Defined Types, you can also use built-in types like string and int:
param parArrayOfStrings string[] // Enforces the array to only contain strings
param parArrayOfInts int[] // Enforced the array to only contain integers
Avoid complex operations within definitions
Lastly, avoid performing complex operations within your resource or module definitions. A complex operation can involve the manipulation of an object, string, or array, or calling a user-defined function with many input parameters.
To increase readability, it is better practice to place these complex operations in a Bicep variable. This way, the resource or module definitions only contain references to either parameters or variables.
In bicepparam parameter files, the case is different. These files contain only parameter definitions, not resource or module definitions. Since variables are supported in bicepparam files, you can do the same thing here. Define your variables to handle the complex operations. Then refer to the variables in the parameter.
In the Bicep snippet below, you see the creation of a route table and a virtual network. This snippet also includes a user-defined function and type. All complex operations, like generating deployment names or formatting the subnet array to a specific format requested by the external Azure Verified Module, are done within the variables.
| param parSubnet typeSubnetConfig = [{ | |
| name: 'subnet-name' | |
| addressPrefix: '192.168.1.0/24' | |
| routeTableName: 'route-table-name' | |
| }] | |
| var varDeploymentNames = { | |
| routeTableDeploymentName: funcGenerateDeploymentName('rt', 'route-table-var-demo') | |
| virtualNetworkDeploymentName: funcGenerateDeploymentName('vnet', 'virtual-network-var-demo') | |
| } | |
| var varFormattedSubnets = map(parSubnet, subnet => { | |
| name: subnet.name | |
| addressPrefix: subnet.addressPrefix | |
| routeTableResourceId: resourceId('Microsoft.Network/routeTables', subnet.routeTableName) | |
| }) | |
| module modRouteTable 'br/public:avm/res/network/route-table:0.3.0' = { | |
| name: varDeploymentNames.routeTableDeploymentName | |
| params: { | |
| name: 'route-table-name' | |
| } | |
| } | |
| module modVirtualNetwork 'br/public:avm/res/network/virtual-network:0.2.0' = { | |
| name: varDeploymentNames.virtualNetworkDeploymentName | |
| dependsOn: [ | |
| modRouteTable | |
| ] | |
| params: { | |
| name: 'virtual-network-name' | |
| addressPrefixes: [ | |
| '192.168.1.0/24' | |
| ] | |
| subnets: varFormattedSubnets | |
| } | |
| } | |
| func funcGenerateDeploymentName(resourceAbbreviation string, name string) string => 'deploy-module-${resourceAbbreviation}-${uniqueString(resourceAbbreviation, name)}' | |
| type typeSubnetConfig = { | |
| name: string | |
| addressPrefix: string | |
| routeTableName: string | |
| }[] |
Conclusion
In conclusion, writing readable Azure Bicep templates are essential for maintainable infrastructure. By adopting consistent coding conventions, implementing a clear structure, leveraging User-Defined Types, and moving complex operations to variables, you can enhance the readability of your Bicep templates.
These practices not only make your templates more accessible to others, but also reduce errors. Invest time to improve the readability of your Bicep templates and it will pay off in the long run with more robust and maintainable Azure Bicep templates!