Every team eventually reaches the point where their Infrastructure as Code stops being “a few clean resource declarations” and starts becoming a living model of real environments. Dev, test, staging, production, shared services, private endpoints, diagnostics, optional add-ons, regional differences — the template starts getting opinions.
That is where Azure Bicep’s conditional and iterative deployments become incredibly useful. With if conditions and forloops, you can keep one Bicep file flexible without turning it into a copy-paste museum. But these features also come with a few sharp edges. They are not dangerous, exactly, but they do expect you to understand how Bicep translates your intent into an ARM deployment.
The big idea is simple: conditions decide whether something exists, and loops decide how many copies exist. The practical challenge is making sure the rest of your template does not accidentally assume more certainty than you actually have.
Conditional Resources: “Maybe” Is Now Part of Your Template
A conditional resource in Bicep uses the if expression directly on the resource or module declaration. When the condition evaluates to true, the resource is deployed. When it evaluates to false, the resource is not created. Microsoft’s Bicep documentation is explicit about this: the condition applies to the whole resource or module, not part of it.
Here is the basic pattern:
param deployDiagnostics bool = trueparam location string = resourceGroup().locationresource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (deployDiagnostics) { name: 'law-${uniqueString(resourceGroup().id)}' location: location properties: { sku: { name: 'PerGB2018' } retentionInDays: 30 }}
That is clean and readable. The trouble begins when another resource assumes logAnalytics always exists.
resource appInsights 'Microsoft.Insights/components@2020-02-02' = { name: 'appi-${uniqueString(resourceGroup().id)}' location: location kind: 'web' properties: { Application_Type: 'web' // Problem: logAnalytics may not exist WorkspaceResourceId: logAnalytics.id }}
This is the IaC version of inviting someone to dinner who may or may not live at the address you wrote down.
Bicep will warn you about this with diagnostic code BCP318 when you try to access a property on a conditional resource that may be null. The warning exists for a good reason: if the resource is not deployed, that reference may fail at deployment time.
The better approach is to make the dependent behavior conditional too.
resource appInsights 'Microsoft.Insights/components@2020-02-02' = { name: 'appi-${uniqueString(resourceGroup().id)}' location: location kind: 'web' properties: { Application_Type: 'web' WorkspaceResourceId: deployDiagnostics ? logAnalytics.id : null }}
That small ternary expression communicates something important: “This value only exists when the resource exists.” Bicep is good, but it is not a mind reader. We need to model the maybe.
Conditional Deployment Does Not Cascade
One of the most common gotchas is assuming a condition on a parent resource automatically applies to child resources. It does not. Microsoft’s documentation calls this out directly: conditional deployment does not cascade to child resources. If you conditionally deploy a resource and its child resources, apply the condition to each resource type.
For example, this can surprise people:
param deployStorage bool = falseparam location string = resourceGroup().locationresource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = if (deployStorage) { name: 'st${uniqueString(resourceGroup().id)}' location: location sku: { name: 'Standard_LRS' } kind: 'StorageV2'}resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { name: '${storage.name}/default'}
The storage account is conditional, but the child resource is not. That can lead to a deployment that tries to configure something whose parent was never created.
The better version makes the condition explicit on both:
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = if (deployStorage) { name: 'st${uniqueString(resourceGroup().id)}' location: location sku: { name: 'Standard_LRS' } kind: 'StorageV2'}resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = if (deployStorage) { name: '${storage.name}/default'}
This may feel repetitive, but it is honest. Infrastructure templates benefit from boring honesty.
Be Careful with Runtime Functions on Conditional Resources
Another subtle issue appears when you use runtime functions such as reference() or listKeys() with resources that may not exist. Even if the resource is conditional, those functions can still be evaluated unless you guard them with a conditional expression. Microsoft’s guidance is to use the ternary operator so those functions are only evaluated when valid.
The common mistake looks like this:
param deployWorkspace bool = falseparam workspaceId string = ''var workspaceKey = listKeys(workspaceId, '2022-10-01').primarySharedKey
If workspaceId is empty or refers to something that was not deployed, this can fail even though your high-level intention was “only use this when diagnostics are enabled.”
The safer approach is this:
param deployWorkspace bool = falseparam workspaceId string = ''var workspaceKey = deployWorkspace ? listKeys(workspaceId, '2022-10-01').primarySharedKey : null
This pattern is especially important when building templates that support optional monitoring, optional private endpoints, optional customer-managed keys, or “new or existing” resource patterns.
Resource Loops: Great for Consistency, Dangerous for Names
Bicep loops are one of the best ways to remove repetition from your infrastructure code. Instead of declaring three storage accounts, five subnets, or twenty role assignments by hand, you describe the collection and let Bicep generate the instances. Bicep supports loops for resources, modules, variables, properties, and outputs.
Here is a straightforward resource loop:
param location string = resourceGroup().locationvar storageAccounts = [ { name: 'appdata' sku: 'Standard_LRS' } { name: 'logs' sku: 'Standard_GRS' } { name: 'backup' sku: 'Standard_ZRS' }]resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = [for account in storageAccounts: { name: 'st${account.name}${uniqueString(resourceGroup().id)}' location: location sku: { name: account.sku } kind: 'StorageV2'}]
This is the good kind of lazy. We define the differences once and let the template handle the repetition.
The catch is that each resource instance still needs a unique name. Bicep does not magically solve Azure naming constraints for you. The documentation notes that when using loops to create multiple resources or modules, each instance must have a unique name property.
The common mistake is using a loop while accidentally generating the same name each time:
resource badStorage 'Microsoft.Storage/storageAccounts@2023-05-01' = [for account in storageAccounts: { // Bad: every loop iteration gets the same name name: 'st${uniqueString(resourceGroup().id)}' location: location sku: { name: account.sku } kind: 'StorageV2'}]
The better approach is to include something unique from the item or the index:
resource goodStorage 'Microsoft.Storage/storageAccounts@2023-05-01' = [for (account, i) in storageAccounts: { name: 'st${account.name}${i}${uniqueString(resourceGroup().id)}' location: location sku: { name: account.sku } kind: 'StorageV2'}]
That does not mean every resource name should use the index. In fact, I usually prefer stable business names from the data model whenever possible. Indexes are useful, but they are also fragile if the array order changes.
The Hidden Risk of Index-Based Names
Indexes are convenient. They are also a little sneaky.
Suppose you deploy subnets from this array:
var subnets = [ { name: 'web' prefix: '10.0.1.0/24' } { name: 'api' prefix: '10.0.2.0/24' } { name: 'data' prefix: '10.0.3.0/24' }]
Using the subnet name is stable:
subnets: [for subnet in subnets: { name: subnet.name properties: { addressPrefix: subnet.prefix }}]
Using the index as the identity is more fragile:
subnets: [for (subnet, i) in subnets: { name: 'subnet-${i}' properties: { addressPrefix: subnet.prefix }}]
That may work fine until someone inserts a new subnet at the beginning of the array. Now every index shifts. Depending on the resource type, this can cause churn, replacement, or confusing diffs. The template did what you asked, but what you asked was tied to list position rather than intent.
The rule of thumb is simple: use indexes for ordering and uniqueness helpers, not as your primary identity when the item already has a meaningful name.
Resource Loops vs. Variable and Output Loops
Not all Bicep loops create Azure resources. That distinction matters.
A resource loop creates multiple deployed resource instances:
resource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' = [for name in nsgNames: { name: 'nsg-${name}' location: resourceGroup().location}]
A variable loop creates a value inside the template:
var nsgNames = [ 'web' 'api' 'data']var nsgResourceNames = [for name in nsgNames: 'nsg-${name}']
An output loop shapes the deployment output:
output deployedNsgs array = [for (name, i) in nsgNames: { logicalName: name resourceName: nsg[i].name resourceId: nsg[i].id}]
These are related, but they are not interchangeable.
Resource loops affect the deployment graph. They create resources, dependencies, operations, and potentially parallel execution. Variable and output loops are expression-building tools. They help shape data, build arrays, transform objects, and return useful information.
Here is the practical difference:
| Loop Type | What It Does | Common Use |
|---|---|---|
| Resource loop | Deploys multiple Azure resources | Storage accounts, NSGs, role assignments, private endpoints |
| Module loop | Deploys a module multiple times | Reusable regional deployments, per-app infrastructure |
| Property loop | Generates repeated nested properties | Subnets in a VNet, backend pools, rules |
| Variable loop | Builds transformed data | Naming conventions, filtered arrays, derived configuration |
| Output loop | Returns structured deployment results | IDs, endpoints, names, diagnostic values |
This distinction matters because resource and module loops are constrained by deployment behavior. For example, loop iterations must be determinable at the start of deployment, and loop iterations cannot exceed 800. Variable and output loops are usually where you want to do data shaping before deployment logic becomes resource logic.
Filtering in Loops: The Index Is Still the Original Index
Bicep lets you combine loops with conditions. This is useful when you have a collection of possible resources, but only some should be deployed.
var apps = [ { name: 'web' enabled: true } { name: 'worker' enabled: false } { name: 'api' enabled: true }]resource appPlans 'Microsoft.Web/serverfarms@2023-12-01' = [for app in apps: if (app.enabled) { name: 'plan-${app.name}' location: resourceGroup().location sku: { name: 'B1' tier: 'Basic' }}]
This deploys plans for web and api, but skips worker.
The gotcha comes when you use the index:
resource appPlans 'Microsoft.Web/serverfarms@2023-12-01' = [for (app, i) in apps: if (app.enabled) { name: 'plan-${i}-${app.name}' location: resourceGroup().location sku: { name: 'B1' tier: 'Basic' }}]
The index i comes from the original array iteration. It does not become a nice compact “deployed resource number.” In the example above, the deployed names would be:
plan-0-webplan-2-api
There is no plan-1-* because the second item was filtered out.
That is not necessarily wrong, but it can be surprising. If downstream logic assumes filtered resources have contiguous indexes, you are building on sand.
The better approach is to use meaningful item properties for names and references:
resource appPlans 'Microsoft.Web/serverfarms@2023-12-01' = [for app in apps: if (app.enabled) { name: 'plan-${app.name}' location: resourceGroup().location sku: { name: 'B1' tier: 'Basic' }}]
If you need a compact filtered collection, build that collection intentionally before using it. In newer Bicep patterns, lambda functions such as filter() and map() can help shape data more clearly before it becomes deployment logic. The point is not to be fancy. The point is to make the collection you loop over match the resources you intend to deploy.
When Loops Cause Unexpected Behavior
Loops are great until they accidentally amplify a bad assumption. One wrong name in a single resource declaration is a bug. One wrong name in a loop is a bug factory.
The most common surprises tend to fall into a few categories.
First, parallel deployment is the default. Azure resources in a loop are deployed concurrently unless dependencies say otherwise. Microsoft’s documentation states that looped resource instances are deployed at the same time by default and creation order is not guaranteed. That is usually good for speed, but not always good for reliability.
Second, dependencies may not be what you think they are. If each resource references another resource correctly, Bicep can infer dependencies. But if you rely on naming conventions instead of actual symbolic references, ARM may not know one thing should wait for another.
Third, array order becomes part of your infrastructure contract when you use indexes for names, references, or outputs. That may be acceptable for throwaway test environments, but it is risky for long-lived production resources.
Fourth, resource provider throttling is real. Some Azure providers are perfectly happy with high parallelism. Others can get grumpy when you ask for too much at once. Infrastructure deployment is not a race car simply because you found the gas pedal.
Using batchSizefor Reliability, Not Just Control
Bicep includes a @batchSize() decorator that can be applied to resource or module loops. This limits how many loop instances deploy concurrently. When you set a batch size, Bicep creates dependencies between batches so the next group waits for the previous group to complete.
Here is the basic idea:
param location string = resourceGroup().locationvar apps = [ 'web' 'api' 'worker' 'jobs' 'admin']@batchSize(2)resource appPlans 'Microsoft.Web/serverfarms@2023-12-01' = [for app in apps: { name: 'plan-${app}-${uniqueString(resourceGroup().id)}' location: location sku: { name: 'B1' tier: 'Basic' }}]
This deploys two instances at a time instead of trying to deploy all of them in parallel.
You do not need batchSize everywhere. For small deployments, it often adds no value. But it matters when you are dealing with resources that are slow, stateful, quota-sensitive, or prone to provider throttling. It can also be useful for production changes where updating every instance at once is operationally risky.
Good candidates for batchSize include:
- Large module loops that deploy complete application stacks
- Role assignment loops that may hit authorization propagation timing
- Private endpoint or networking-heavy deployments
- Production updates where blast radius matters
- Resource providers known to throttle or behave inconsistently under high parallelism
The tradeoff is speed. Smaller batches are safer but slower. Larger batches are faster but can put more pressure on Azure Resource Manager and the underlying resource providers. There is no universal perfect value. A batch size of 1 gives you serial deployment. A value like 5 or 10 may be a reasonable middle ground for larger but predictable deployments.
The Common Way vs. the Better Way
The common way to use Bicep conditionals and loops is to start with syntax. We ask, “How do I make this resource optional?” or “How do I deploy ten of these?” That works for getting started, but it misses the bigger design question.
The better way is to start with the deployment model.
Ask:
- Which resources are truly optional?
- Which resources depend on optional resources?
- Which values should be stable identities instead of generated indexes?
- Which loops are deploying infrastructure, and which loops are just shaping data?
- Which operations can safely run in parallel?
- Which operations need throttling or sequencing?
That mindset produces cleaner Bicep because the template begins to reflect real operational intent. You are not just writing less code. You are writing infrastructure code that behaves predictably when environments differ.
Practical Checklist for Conditional and Iterative Bicep
Before you commit that elegant-looking loop or conditional block, run through this quick checklist:
- Guard every reference to a conditional resource. Use ternary expressions, safe dereferencing, or restructure the deployment so dependent resources are also conditional.
- Apply conditions to child resources explicitly. Conditions do not cascade.
- Avoid using indexes as long-term resource identity. Prefer stable names from your configuration data.
- Make loop input deterministic. Loops must be based on values known at the start of deployment.
- Keep filtered loops easy to reason about. Remember that indexes reflect the original collection, not a compact filtered list.
- Use symbolic references for dependencies. Do not rely only on matching strings when a real resource reference is available.
- Use
@batchSize()when reliability matters more than raw parallelism. - Output useful deployment results. When looping resources or modules, structured outputs can make downstream pipelines much easier to work with.
A More Complete Example
Here is a compact example that brings several of these ideas together. It conditionally deploys diagnostics, loops through app definitions, filters disabled apps, avoids index-based identity, and uses batching for safer deployment.
param location string = resourceGroup().locationparam deployDiagnostics bool = truevar apps = [ { name: 'web' enabled: true sku: 'B1' } { name: 'worker' enabled: false sku: 'B1' } { name: 'api' enabled: true sku: 'P0v3' }]resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (deployDiagnostics) { name: 'law-${uniqueString(resourceGroup().id)}' location: location properties: { sku: { name: 'PerGB2018' } retentionInDays: 30 }}@batchSize(2)resource plans 'Microsoft.Web/serverfarms@2023-12-01' = [for app in apps: if (app.enabled) { name: 'plan-${app.name}-${uniqueString(resourceGroup().id)}' location: location sku: { name: app.sku tier: app.sku == 'P0v3' ? 'PremiumV3' : 'Basic' }}]resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = [for (app, i) in apps: if (app.enabled && deployDiagnostics) { name: 'diag-${app.name}' scope: plans[i] properties: { workspaceId: workspace.id logs: [] metrics: [ { category: 'AllMetrics' enabled: true } ] }}]output deployedPlans array = [for (app, i) in apps: if (app.enabled) { appName: app.name planName: plans[i].name planId: plans[i].id diagnosticsEnabled: deployDiagnostics}]
There is one important nuance in this example: the plans[i] reference works because the diagnosticSettings loop uses the same source array and the same filter logic. In larger templates, I would be cautious with this pattern. If the filters drift apart, the references can become confusing. For production-grade modules, it is often cleaner to pre-shape the enabled app collection and loop over that consistently.
Conclusion
Conditional and iterative deployments are not just Bicep conveniences. They are how we model real infrastructure without duplicating ourselves into a corner. Optional resources let one template serve multiple environments. Loops let one declaration represent a fleet of similar resources. Together, they help us write infrastructure code that is smaller, clearer, and closer to how teams actually operate.
But the power comes with responsibility. A conditional resource is a maybe. A loop is a multiplier. If you reference maybes carelessly or multiply assumptions too aggressively, your deployment can become unpredictable in exactly the places you wanted it to be automated.
The best Bicep templates are not the cleverest ones. They are the ones another engineer can open six months later and understand without needing a whiteboard, a deployment failure, and three cups of coffee.
Key Takeaways
- Use
ifon resources and modules when the whole resource is optional. - Remember that conditional deployment does not cascade to child resources.
- Guard references to conditional resources, especially when using runtime functions like
reference()orlistKeys(). - Use
forloops to remove repetition, but keep resource names stable and unique. - Treat resource loops differently from variable and output loops; one changes the deployment graph, the others shape data.
- Be careful with filtered loops because indexes come from the original collection.
- Use
@batchSize()when parallel deployment creates reliability, throttling, or operational risk.
How are you using Bicep loops and conditions in your Azure environments today — as simple conveniences, or as part of a larger reusable deployment strategy?