How to Collect Outputs from a Bicep Module Loop
By ResourcePulse Team · · 5 min read
Deploying a module once is easy to wire up. Deploying it in a for loop and then reading the outputs back out is where people get stuck — myModule.outputs.id stops working the moment the module becomes an array, and the error messages do not make the fix obvious.
This post shows how to reference a single looped output, how to collect all of them into an array, and how to return more than one property per iteration.
Why the outputs reference breaks in a loop
When you deploy a module without a loop, it is a single object and you reference its outputs directly:
module storage './storage.bicep' = {
name: 'storage'
params: {
accountName: 'mydata'
}
}
output storageId string = storage.outputs.storageAccountId
Add a for and storage is no longer one module — it is a collection. (If you also need to feed the loop from typed data, see how to pass an array of objects to a Bicep module.)
var environments = ['dev', 'staging', 'prod']
module storage './storage.bicep' = [for env in environments: {
name: 'storage-${env}'
params: {
accountName: 'data${env}'
}
}]
// This fails:
output storageId string = storage.outputs.storageAccountId
Bicep reports something like "The language expression property 'outputs' doesn't exist" or "Cannot access 'outputs' on a value of type module[]". The collection has no outputs of its own — each item does. You have to index into it.
Reading one output by index
If you only need a specific iteration, reference it by position. The index matches the order of the array you looped over:
output devStorageId string = storage[0].outputs.storageAccountId
output prodStorageId string = storage[2].outputs.storageAccountId
This works but it is fragile: reorder environments and the indices silently point at the wrong resource. Use it only when the position is genuinely fixed.
Collecting every output into an array
Most of the time you want all of them. Use a for comprehension over the same range you deployed with:
output allStorageIds array = [
for i in range(0, length(environments)): storage[i].outputs.storageAccountId
]
range(0, length(environments)) produces [0, 1, 2], and each iteration pulls the output from the matching module instance. The result is an array of IDs in the same order as environments.
You can also loop over the source array directly when you need the loop variable, though indexing is still required to reach the module instance:
output allStorageIds array = [
for (env, i) in environments: storage[i].outputs.storageAccountId
]
Returning more than one property per item
A single value per iteration is rarely enough. To carry the name alongside the ID, emit an object on each pass:
output storageAccounts array = [
for (env, i) in environments: {
environment: env
id: storage[i].outputs.storageAccountId
endpoint: storage[i].outputs.primaryBlobEndpoint
}
]
The consuming template, or the deployment output, now gets a structured array:
[
{ "environment": "dev", "id": "/subscriptions/.../dev", "endpoint": "https://datadev.blob.core.windows.net/" },
{ "environment": "staging", "id": "...", "endpoint": "..." },
{ "environment": "prod", "id": "...", "endpoint": "..." }
]
For this to compile, every property you reference (storageAccountId, primaryBlobEndpoint) must actually be declared as an output inside storage.bicep. Because the module is local, Bicep knows its outputs and catches a typo like storageAccountld at build time rather than letting it reach a deployment. (The runtime language expression property doesn't exist error is more typically a property read on a deployed resource, not a module output name.)
Feeding looped outputs into the next module
A common reason to collect outputs is to pass them on — for example, granting an app access to every storage account you just created. Reference the collected array, or index a single instance, in the downstream module's params:
module roleAssignments './roles.bicep' = {
name: 'roles'
params: {
storageAccountIds: [
for i in range(0, length(environments)): storage[i].outputs.storageAccountId
]
}
}
Bicep infers the dependency from the storage[i].outputs reference, so the loop finishes before roles runs. You do not need an explicit dependsOn.
Conditional loops break the index alignment
If the module loop also carries a condition, the instances whose condition is false are never deployed:
module storage './storage.bicep' = [for env in environments: if (env != 'prod') {
name: 'storage-${env}'
params: { accountName: 'data${env}' }
}]
The trap is an output loop that still walks the full source array. Skipped instances are dropped from the collection entirely, so it shrinks rather than leaving a gap — storage now holds two modules, not three. The first symptom is usually silent: if a middle environment is filtered out, everything after it slides left, so storage[1] quietly returns the wrong account's output. Only when the loop indexes past the end of the shortened collection do you get a hard failure, with Azure reporting that the resource isn't defined in the template. Either way, the indices no longer line up with environments.
The robust fix is to filter the source array before the loop, so every instance deploys and the indices stay contiguous:
var deployedEnvironments = filter(environments, env => env != 'prod')
module storage './storage.bicep' = [for env in deployedEnvironments: {
name: 'storage-${env}'
params: { accountName: 'data${env}' }
}]
output allStorageIds array = [
for i in range(0, length(deployedEnvironments)): storage[i].outputs.storageAccountId
]
Now the loop and the output range read from the same filtered array. Mixing if into a loop you also read outputs from, while still indexing against the unfiltered array, is the most common source of "works in dev, breaks in prod" deployment failures.
Each looped instance is a separate cost line
A module loop is a convenient way to stamp out one resource per environment — and a convenient way to multiply spend without noticing. Three environments in the array means three storage accounts; ten means ten, each billed independently. When the loop feeds off a parameter file, a single edit to the array (or to a sku default inside the module) scales across every instance at once.
If your team reviews Bicep on GitHub, ResourcePulse posts an estimated monthly cost for each resource a pull request adds or changes, looped module instances included, so a loop that quietly grows from three to thirteen accounts shows up in the diff. The Preview tier is free on one repository and needs no Azure subscription access.