Azure Bicep

Conditional & Iterative Deployments with Azure Bicep

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 = true
param location string = resourceGroup().location
resource 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 = false
param location string = resourceGroup().location
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' = {
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 = false
param 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 = false
param 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().location
var 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 TypeWhat It DoesCommon Use
Resource loopDeploys multiple Azure resourcesStorage accounts, NSGs, role assignments, private endpoints
Module loopDeploys a module multiple timesReusable regional deployments, per-app infrastructure
Property loopGenerates repeated nested propertiesSubnets in a VNet, backend pools, rules
Variable loopBuilds transformed dataNaming conventions, filtered arrays, derived configuration
Output loopReturns structured deployment resultsIDs, 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-web
plan-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().location
var 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().location
param deployDiagnostics bool = true
var 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 if on 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() or listKeys().
  • Use for loops 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?

Related Articles

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.