Bicep is the language. ARM is the engine. This note is the lookup page: how a file is shaped, how parameters and .bicepparam work, every decorator you actually use, resources (symbolic names, nested, existing), modules across scopes, loops and conditions, the four target scopes with their az deployment commands, the functions that show up in real templates, user-defined types and functions, outputs, and five traps that waste an afternoon.
This is not a learning path. If you want order plus labs, use the Bicep learning roadmap. If you want the syntax while you are in a file, stay here.
📄 File shape
A .bicep file is declarations. Order does not matter. Bicep builds the graph.
targetScope = 'resourceGroup' // optional. resourceGroup is the default
param location string = resourceGroup().location
var namePrefix = 'app'
resource stg 'Microsoft.Storage/storageAccounts@2023-05-01' = { ... }
output blobEndpoint string = stg.properties.primaryEndpoints.blobTypical order people use, only because it reads well: targetScope, metadata, params, variables, resources and modules, outputs.
Compile check without deploying:
az bicep build --file main.bicep
az bicep format --file main.bicepThe Bicep extension's "Insert Resource" command is how you stop guessing API versions. Pick the type, take the current version, then freeze it. Do not chase "latest" on every save.
🎛️ Parameters
@description('Azure region for all resources')
param location string = resourceGroup().location
@allowed(['dev', 'test', 'prod'])
param env string
@minLength(3)
@maxLength(24)
param storagePrefix string
@secure()
param adminPassword string| Kind | When you use it |
|---|---|
Required param name type | Caller must pass it |
Default = expression | Optional. Evaluated at deploy time |
@secure() | Secrets. Never in outputs. Never in plaintext logs |
Pass values at deploy:
az deployment group create \
--resource-group rg-demo \
--template-file main.bicep \
--parameters env=dev storagePrefix=appstg📦 .bicepparam files
Do not keep environment values in the template. Keep them next to it.
// main.bicepparam
using 'main.bicep'
param env = 'dev'
param location = 'canadacentral'
param storagePrefix = 'appstg'az deployment group create \
--resource-group rg-demo \
--template-file main.bicep \
--parameters main.bicepparamOne template. One param file per environment. Same pattern as Terraform .tfvars, without a state file.

Expressions, parameters, variables, outputs
The lab that turns a one-off template into something you can reuse across environments.
🏷️ Decorators
Decorators sit above the declaration they change.
| Decorator | On | What it does |
|---|---|---|
@description('...') | param, type, func, output | Docs in the portal and compiled ARM |
@allowed([...]) | param | Enum. Prefer this over a comment |
@minLength / @maxLength | string or array param | Length bounds |
@minValue / @maxValue | int param | Numeric bounds |
@secure() | param | Marks a secret |
@metadata({...}) | param | Extra portal/tooling hints |
@discriminator('type') | union type | Tagged union for object variants |
@export() | type, var, func | Makes it importable from another file |
@sealed() | user-defined type | Rejects extra properties |
@batchSize(n) | resource or module loop | Parallelism cap for the loop |
@allowed and @description on every public parameter. @secure the moment a value is a password, a key, or a connection string. @batchSize only after a loop 429s or thrashes a subscription.
🧱 Resources and symbolic names
resource stg 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
}
}The symbolic name (stg) is how the rest of the file talks to this resource. The ARM type string is 'Provider/type@apiVersion'. Pin the API version.
| You want | You write |
|---|---|
| Resource id | stg.id |
| Name | stg.name |
| A property | stg.properties.primaryEndpoints.blob |
| A child name | stg.name plus the child segment, or parent: |
Bicep infers dependsOn from those references. If you are typing dependsOn, you probably missed a property reference.
🔗 Nested and existing resources
Child with parent: (name is only the child segment):
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
parent: stg
name: 'default'
}
resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = {
parent: blobService
name: 'data'
}Reference something that already exists. This does not deploy it:
resource existingStg 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
name: storageName
}
output alreadyThere string = existingStg.idCross-resource-group existing:
resource existingStg 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
name: storageName
scope: resourceGroup('rg-shared')
}existing is a read at deploy time. Wrong name or API version and the deploy fails at ARM, not at compile. The compile will still look clean.
📦 Modules and cross-scope deploys
module storage 'modules/storage.bicep' = {
name: 'storageDeploy'
params: {
name: storageName
location: location
}
}
output blobEndpoint string = storage.outputs.blobEndpointThe name on a module is the nested deployment name in ARM. Keep it stable. Changing it looks like a new nested deployment.
Deploy a module into another resource group:
module network 'modules/vnet.bicep' = {
name: 'networkDeploy'
scope: resourceGroup('rg-network')
params: {
location: location
}
}Episode 4 of the series is this pattern: one output in, one parameter out. Modules, one output.

Deploy a VNet and VM with Bicep
A real stack, not a storage account. Dependencies, networking, and a VM in one template.
🔁 Loops
param subnets array
resource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' = [
for (s, i) in subnets: {
name: 'nsg-${s.name}'
location: location
properties: {
securityRules: []
}
}
]| Form | Use |
|---|---|
for item in collection | Walk an array |
for (item, i) in collection | You need the index |
for i in range(0, count) | Fixed count |
if condition after the for | Filter items out of the loop |
Throttle a noisy loop:
@batchSize(3)
resource nics 'Microsoft.Network/networkInterfaces@2023-09-01' = [
for vm in vms: {
name: 'nic-${vm.name}'
// ...
}
]You cannot nest resource loops inside resource loops. Push the inner loop into a module and loop the module.
❓ Conditions and operators
Resource exists or it does not:
param deployBastion bool
resource bastion 'Microsoft.Network/bastionHosts@2023-09-01' = if (deployBastion) {
name: 'bastion'
// ...
}| Operator | Meaning |
|---|---|
cond ? a : b | Ternary |
a ?? b | If a is null, use b |
obj.?prop | Safe navigate. Missing property becomes null |
item in list / contains() | Membership |
var skuName = env == 'prod' ? 'Standard_GRS' : 'Standard_LRS'
var endpoint = stg.?properties.?primaryEndpoints.?blob ?? ''
Conditions, loops, what-if
The production patterns: optional resources, copy loops, and previewing the change before you apply it.
🌐 Four scopes
targetScope at the top of the file has to match the az deployment command you run.
targetScope | Deploy with | Needs |
|---|---|---|
'resourceGroup' (default) | az deployment group create --resource-group <rg> | RG already exists |
'subscription' | az deployment sub create --location <region> | Often used to create RGs |
'managementGroup' | az deployment mg create --management-group-id <id> --location <region> | MG write permission |
'tenant' | az deployment tenant create --location <region> | Tenant-wide, rare |
# what-if before you apply
az deployment group what-if \
--resource-group rg-demo \
--template-file main.bicep \
--parameters main.bicepparamModule scope: is how a resourceGroup template reaches another RG, or a subscription template reaches a specific RG. Mixing this up is the usual "why did this land in the wrong place" ticket.
🧰 Functions you actually use
Everything else is in the docs. These show up in real templates.
| Function | Typical use |
|---|---|
resourceGroup() / .location / .id | Default region, unique seeds |
subscription() / tenant() | Scope ids |
uniqueString(...) | Short, stable hash for names |
guid(...) | Stable GUID from seeds |
utcNow() | Timestamps. Default values only, not resource bodies that must be idempotent |
'${a}-${b}' | String interpolation. Prefer this over concat() |
format('{0}-{1}', a, b) | Same idea, numbered |
toLower() / replace() / substring() | Name cleanup |
length() / empty() / contains() | Guards |
first() / last() / take() / skip() | Array slices |
union() / intersection() | Object or array merge |
items() / keys() | Walk an object |
loadJsonContent() / loadTextContent() | Compile-time file load |
resourceId(type, ...names) | Build an id when you do not have a symbolic ref |
existing.listKeys().keys[0].value | Keys from an existing account. Treat as secret |
param prefix string
var stgName = toLower('st${prefix}${uniqueString(resourceGroup().id)}')uniqueString(resourceGroup().id) is how storage account names stay globally unique without becoming random every deploy.
🧩 User-defined types and functions
@export()
type subnet = {
name: string
addressPrefix: string
}
param subnets subnet[]
func storageName(prefix string) string => toLower('st${prefix}${uniqueString(resourceGroup().id)}')
var stgName = storageName('app')Put shared types in types.bicep, @export() them, then import { subnet } from 'types.bicep'. That is the same idea as a Terraform module's variables file, minus the state argument.
@sealed() on a type rejects extra properties. Use it when a param object should not silently accept junk.
📤 Outputs
output storageId string = stg.id
output blobEndpoint string = stg.properties.primaryEndpoints.blob
output nsgIds array = [for (s, i) in subnets: nsg[i].id]Read them after deploy:
az deployment group show \
--resource-group rg-demo \
--name main \
--query properties.outputsModules expose outputs as moduleName.outputs.foo. That is the contract between modules. Do not reach into another module's resources.
You cannot output an @secure() parameter. ARM will refuse it. If a caller needs a secret, they already have it, or they read it from Key Vault at runtime, not from a deployment output.
⚠️ Five traps
| Trap | What happens | What to do |
|---|---|---|
| Hardcoded names | Second deploy to another RG collides, or a storage name is taken globally | uniqueString(resourceGroup().id) in the name. Keep it deterministic |
existing with a guessed API version | Compile succeeds. Deploy fails looking up the resource | Match the type version you would use to deploy it |
| Nested resource loops | Bicep will not let you, or you invent dependsOn spaghetti | Inner loop lives in a module. Outer loop deploys the module |
Outputting @secure() values | ARM error at deploy | Key Vault reference, or the caller already knows the secret |
Wrong az deployment for targetScope | CLI error, or resources in an unexpected subscription | Table above. group / sub / mg / tenant must match the file |
Run what-if on anything that already exists. A clean plan with noChange on the resources you care about is the cheapest test you have. The lab for that is the conditions and loops one above.
🔗 Where to go next
- Azure Bicep Learning Roadmap - five phases, labs, the YouTube series
- Azure CLI Cheatsheet - the commands around the template
- Terraform CLI Field Notes - if you already think in plan/apply

CloudLearn: Bicep from first template to production
The labs linked above, in order, in a real Azure environment. No subscription of your own required.