You loop through a string array to generate three deployment stages. Then the requirements change: each environment needs a region code, a service connection, and a flag for whether blue-green is enabled. You hit the wall — ${{ each }} only gives you one value per iteration.
Most documentation on ${{ each }} stops at string arrays. Real-world pipelines operate on structured data: environment objects with multiple properties, nested region-to-tier mappings, and key-value configuration maps. Knowing how to iterate simple lists is table stakes. Knowing how to destructure a nested object mid-loop, filter iterations with inline ${{ if }}, and generate unique stage names from compound keys separates a 30-line template from a 300-line template.
This article covers:
- How
${{ each }}behaves differently on arrays vs. mappings, and whatkeyandvaluerefer to in each case. - Patterns for iterating over arrays of objects with multiple properties.
- Nested
${{ each }}loops and the exact limits that make them dangerous. - Filtered iteration with inline
${{ if }}to generate conditional stages without conditional logic at the caller. - Unique stage name generation from compound keys to avoid collision errors.
- The template expansion limits that cap loop depth and total generated items.
The article moves from a review of the basics through five progressively complex iteration patterns, then closes with a complete multi-region, multi-tier deployment generator.
How ${{ each }} Actually Works
The Two Forms — Array and Mapping
${{ each }} operates on two fundamentally different YAML data structures, and the binding behavior differs between them.
When iterating a sequence (array), each iteration binds the loop variable to the element itself. For a string array, the variable is the string. For an array of mappings, the variable is the full mapping — all properties are accessible via dot notation.
When iterating a mapping (key-value object), each iteration binds the variable to a wrapper containing two sub-properties: key (the map key as a string) and value (the map value, which can be a string, number, or nested mapping). This is the only syntax for iterating mapping entries — there is no shorthand.
Both forms are compile-time. The loop unrolls during YAML expansion before the pipeline run starts. The result is a static Expanded YAML that the execution engine processes. You cannot add or remove stages at runtime — the structure is fixed at expansion time.
Diagram: The YAML Expansion Lifecycle (GoAT)
This diagram visualizes how compile-time expressions like ${{ each }} and ${{ if }} are evaluated to generate a static “Expanded YAML” before the pipeline execution phase begins.
Visual Notes:
- Compile-Time: This phase happens on the Azure DevOps server before any agent is assigned. It is where
${{ }}syntax is resolved. - Expanded YAML: This is the final, static structure of the pipeline. You can view this in the portal via “Download full YAML”.
- Runtime: This phase happens during execution. Expressions like
$(var)or$[var]are resolved here, but they cannot change the pipeline’s structure (stages/jobs).
Here are the same three environments expressed as a string array, an array of mappings, and a mapping, with the corresponding ${{ each }} syntax for each:
# Form 1: Array of strings
# env binds to the string value ("dev", "staging", "prod")
parameters:
- name: environments
type: object
default:
- dev
- staging
- prod
stages:
- ${{ each env in parameters.environments }}:
- stage: Deploy_${{ env }}
jobs:
- job: Deploy
pool:
vmImage: ubuntu-latest
steps:
- script: echo "Deploying to ${{ env }}"
# Expands to three stages: Deploy_dev, Deploy_staging, Deploy_prod
# Form 2: Array of mappings
# env binds to the full mapping; access properties via env.name, env.region
parameters:
- name: environments
type: object
default:
- name: dev
region: eastus
serviceConnection: sc-dev-eastus
- name: staging
region: eastus
serviceConnection: sc-staging-eastus
- name: prod
region: westeu
serviceConnection: sc-prod-westeu
stages:
- ${{ each env in parameters.environments }}:
- stage: Deploy_${{ env.name }}
jobs:
- job: Deploy
pool:
vmImage: ubuntu-latest
steps:
- script: echo "Deploying ${{ env.name }} to ${{ env.region }}"
env:
SERVICE_CONNECTION: ${{ env.serviceConnection }}
# Expands to three stages: Deploy_dev, Deploy_staging, Deploy_prod
# Each stage carries its own region and service connection values
# Form 3: Mapping
# sub binds to a wrapper; sub.key is the map key, sub.value is the map value
parameters:
- name: subscriptions
type: object
default:
eastus: sub-001
westeu: sub-002
apsouth: sub-003
stages:
- ${{ each sub in parameters.subscriptions }}:
- stage: Deploy_${{ sub.key }}
jobs:
- deployment: Deploy
environment: ${{ sub.key }}
pool:
vmImage: ubuntu-latest
variables:
subscriptionId: ${{ sub.value }}
strategy:
runOnce:
deploy:
steps:
- script: |
az account set --subscription $(subscriptionId)
echo "Deploying to ${{ sub.key }}"
# Expands to three stages: Deploy_apsouth, Deploy_eastus, Deploy_westeu
# Note: mapping keys iterate in alphabetical order
Variable Binding Names and Scope
The binding name — env, item, region, or anything else — is arbitrary. Choose names that match the data semantics. A loop over environments requires env, not item or x. Readable binding names eliminate a full class of scope bugs when nesting.
The binding scopes to its ${{ each }} block and is not accessible outside it. Inside a nested ${{ each }}, the outer binding stays in scope — which is exactly how you build compound stage names across two loop levels:
# Outer binding (env) remains accessible inside the inner loop
stages:
- ${{ each env in parameters.environments }}:
- ${{ each region in env.regions }}:
- stage: ${{ env.name }}_${{ region.code }}
displayName: "${{ env.name }} — ${{ region.code }}"
jobs:
- deployment: Deploy
environment: ${{ env.name }}-${{ region.code }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying ${{ env.name }} to ${{ region.code }}"
Never reuse a binding name across nested levels. If the outer loop uses env and the inner loop also uses env, the inner binding shadows the outer, and env.name inside the inner loop resolves to the inner item’s name property, not the outer environment’s.
What ${{ each }} Can Generate
${{ each }} works at three levels of pipeline structure:
Stage level — each iteration produces one complete stage: block, including all its jobs and steps. This is the most common pattern for multi-environment deployment pipelines.
Job level — each iteration produces one complete job: or deployment: block inside an existing stage. Use this when the stage structure is fixed but the jobs vary.
Step level — each iteration produces one - script:, - task:, or other step entry inside a job. Use this for data-driven validation checklists or generated test commands.
${{ each }} cannot generate individual YAML properties inside a single task block. If you need to conditionally set a task input (e.g., azureSubscription:), use an inline ${{ if }} on the property value, not ${{ each }}.
# Stage-level: one complete stage per environment
stages:
- ${{ each env in parameters.environments }}:
- stage: Deploy_${{ env.name }}
jobs:
- job: Deploy
pool:
vmImage: ubuntu-latest
steps:
- script: echo "Stage-level generation for ${{ env.name }}"
# Job-level: one deployment job per environment within a fixed stage
stages:
- stage: Deploy
jobs:
- ${{ each env in parameters.environments }}:
- deployment: Deploy_${{ env.name }}
environment: ${{ env.name }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: echo "Job-level generation for ${{ env.name }}"
# Step-level: one validation step per entry in the steps array
stages:
- stage: Validate
jobs:
- job: Validation
pool:
vmImage: ubuntu-latest
steps:
- ${{ each step in parameters.validationSteps }}:
- script: ${{ step.command }}
displayName: ${{ step.name }}
Iterating Arrays of Objects
Single-Level Object Arrays
A parameter declared as type: object with a default: list of mappings gives each ${{ each }} iteration access to all properties of the mapping via ${{ item.propertyName }}. All properties resolve at compile time.
Properties present on every item work without guards. Properties that some items omit resolve to empty string. Use ${{ coalesce(item.optionalProp, 'default') }} to provide a fallback.
# templates/deploy-stage.yml
parameters:
- name: environments
type: object
default: []
stages:
- ${{ each env in parameters.environments }}:
- stage: Deploy_${{ env.name }}
displayName: "Deploy to ${{ env.name }}"
jobs:
- deployment: Deploy
environment: ${{ env.name }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Region: ${{ env.region }}"
echo "Service connection: ${{ env.serviceConnection }}"
# coalesce() provides 'false' when blueGreenEnabled is absent
echo "Blue-green: ${{ coalesce(env.blueGreenEnabled, false) }}"
displayName: "Deploy ${{ env.name }} to ${{ env.region }}"
The calling pipeline passes the environments array with all required properties per item:
# azure-pipelines.yml
stages:
- template: templates/deploy-stage.yml
parameters:
environments:
- name: dev
region: eastus
serviceConnection: sc-dev-eastus
blueGreenEnabled: false
- name: staging
region: eastus
serviceConnection: sc-staging-eastus
blueGreenEnabled: false
- name: prod
region: westeu
serviceConnection: sc-prod-westeu
blueGreenEnabled: true
This generates three deployment stages (Deploy_dev, Deploy_staging, Deploy_prod) with distinct service connections and regions. The blueGreenEnabled property is optional per item; coalesce() ensures a sensible default when omitted.
Generating Unique Identifiers from Object Properties
Stage and job names must be unique within their scope. Two items with the same rendered name produce a parse error before the pipeline runs:
/azure-pipelines.yml (Line: 12, Col: 5): Duplicate identifier 'Deploy_eastus'.
The root cause is typically building the stage name from a single property that is not unique across the array. If two environments share the same region code and the stage name is Deploy_${{ env.region }}, you get a collision. Compose the name from two or more properties:
# Single property — fails when two items share the same region
- stage: Deploy_${{ env.region }} # Deploy_eastus appears twice: parse error
# Compound name — tier + region combination is unique per item
- stage: ${{ env.tier }}_${{ env.region }} # dev_eastus, staging_eastus, prod_westeu
Azure DevOps stage and job names allow only alphanumeric characters and underscores — the character set [A-Za-z0-9_]. Additionally, Stage IDs cannot start with a number. Hyphens, dots, and spaces in property values cause a parse error.
Use the compile-time replace() function to sanitize identifier strings directly in the template. This allows callers to pass standard Azure resource names (which conventionally use hyphens) without breaking the pipeline parser:
parameters:
- name: environments
type: object
default:
# Caller passes standard hyphenated names
- tier: dev
region: east-us
serviceConnection: sc-dev-eastus
- tier: prod
region: west-eu
serviceConnection: sc-prod-westeu
stages:
- ${{ each env in parameters.environments }}:
# Sanitize the tier and region names by replacing hyphens with underscores
- stage: ${{ replace(env.tier, '-', '_') }}_${{ replace(env.region, '-', '_') }}
displayName: "${{ env.tier }} (${{ env.region }})"
jobs:
- deployment: Deploy
# Azure DevOps environment names use hyphen convention, so original value is fine here
environment: ${{ env.tier }}-${{ env.region }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying ${{ env.tier }} to ${{ env.region }}"
Filtering with Inline ${{ if }}
Place a ${{ if condition }} block inside the ${{ each }} body to skip items that do not match a filter criterion. No stage or job is generated for filtered-out items — they are absent from the Expanded YAML entirely.
This approach moves filter logic into the template. The caller provides the full set of environments; the template decides which ones produce stages based on built-in criteria. Callers do not need to pre-filter their arrays before passing them in.
# templates/deploy-stage.yml
parameters:
- name: environments
type: object
default: []
stages:
- ${{ each env in parameters.environments }}:
# Only generate a stage when deployOnPR is explicitly true
- ${{ if eq(env.deployOnPR, true) }}:
- stage: Deploy_${{ env.name }}
displayName: "PR deploy: ${{ env.name }}"
jobs:
- deployment: Deploy
environment: ${{ env.name }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: echo "PR deployment to ${{ env.name }}"
# azure-pipelines.yml — caller provides all five environments unfiltered
stages:
- template: templates/deploy-stage.yml
parameters:
environments:
- name: dev_eastus
deployOnPR: true # Stage generated
- name: dev_westeu
deployOnPR: false # Skipped — absent from Expanded YAML
- name: staging
deployOnPR: true # Stage generated
- name: prod_eastus
deployOnPR: false # Skipped
- name: prod_westeu
deployOnPR: false # Skipped
The Expanded YAML contains exactly two deployment stages: Deploy_dev_eastus and Deploy_staging. The other three never reach the plan.
Iterating Key-Value Mappings
The key / value Binding
When the parameter is a YAML mapping rather than a sequence, ${{ each pair in parameters.regionMap }} binds pair.key to the map key (always a string) and pair.value to the map value. The value can be a string, number, boolean, or a nested mapping.
This is the idiomatic pattern when the key is itself a meaningful identifier — a region code, a tenant ID, a service name — and the value carries supplementary configuration.
One constraint: mapping iteration order is alphabetical by key. If you need stages to run in a specific sequence, use a sequence of objects with explicit dependsOn: properties rather than a mapping.
parameters:
- name: subscriptions
type: object
default:
eastus: sub-001
westeu: sub-002
apsouth: sub-003
stages:
# Iterates alphabetically: apsouth → eastus → westeu
- ${{ each sub in parameters.subscriptions }}:
- stage: Deploy_${{ sub.key }}
displayName: "Deploy to ${{ sub.key }}"
jobs:
- deployment: Deploy
environment: ${{ sub.key }}
variables:
subscriptionId: ${{ sub.value }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: |
az account set --subscription $(subscriptionId)
echo "Deploying to ${{ sub.key }}"
displayName: "Deploy to ${{ sub.key }}"
Mapping Values as Nested Objects
A mapping’s values can be mappings themselves. This is useful when the key is a natural, unique identifier and the value carries a structured configuration payload.
parameters:
- name: regions
type: object
default:
eastus:
subscriptionId: sub-001
tier: prod
blueGreen: true
westeu:
subscriptionId: sub-002
tier: prod
blueGreen: false
dev_eastus:
subscriptionId: sub-dev-001
tier: dev
blueGreen: false
stages:
- ${{ each region in parameters.regions }}:
- stage: Deploy_${{ replace(region.key, '-', '_') }}
displayName: "Deploy to ${{ region.key }} (${{ region.value.tier }})"
jobs:
- deployment: Deploy
environment: ${{ region.key }}
variables:
subscriptionId: ${{ region.value.subscriptionId }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- ${{ if eq(region.value.blueGreen, true) }}:
- script: echo "Blue-green deploy to ${{ region.key }}"
displayName: "Blue-green deployment"
- ${{ else }}:
- script: echo "Rolling deploy to ${{ region.key }}"
displayName: "Rolling deployment"
Access nested properties via region.value.subscriptionId, region.value.tier, and region.value.blueGreen — the same dot-notation as object array iteration, just with the value. prefix between the loop variable and the nested property.
When to Use Mappings vs. Arrays of Objects
| Criterion | Use a mapping | Use an array of objects |
|---|---|---|
| Key is a natural unique identifier (region code, tenant ID) | ✓ | |
| Key appears in stage names or resource references | ✓ | |
| Iteration order is irrelevant | ✓ | |
| Items have no natural unique key | ✓ | |
| Insertion order must be preserved | ✓ | |
Items need explicit dependsOn: for sequencing | ✓ | |
Items will be passed as a jobList with templateContext | ✓ |
The templateContext bridge works with array items passed as jobList — it does not apply to mapping values.
Nested ${{ each }} Loops
The Valid Pattern — Two Levels Deep
Two levels of ${{ each }} are reliable and cover most multi-dimensional deployment scenarios. An outer loop over environments, an inner loop over the regions within each environment, generates one stage per environment-region combination.
The outer binding stays in scope inside the inner loop. Use both bindings to build compound identifiers:
parameters:
- name: environments
type: object
default:
- name: dev
regions:
- code: eastus
serviceConnection: sc-dev-eastus
- code: westeu
serviceConnection: sc-dev-westeu
- name: staging
regions:
- code: eastus
serviceConnection: sc-staging-eastus
- code: westeu
serviceConnection: sc-staging-westeu
- name: prod
regions:
- code: eastus
serviceConnection: sc-prod-eastus
- code: westeu
serviceConnection: sc-prod-westeu
- code: apsouth
serviceConnection: sc-prod-apsouth
stages:
- ${{ each env in parameters.environments }}:
- ${{ each region in env.regions }}:
- stage: ${{ env.name }}_${{ region.code }}
displayName: "${{ env.name }} — ${{ region.code }}"
jobs:
- deployment: Deploy
environment: ${{ env.name }}-${{ region.code }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Environment: ${{ env.name }}"
echo "Region: ${{ region.code }}"
echo "Service connection: ${{ region.serviceConnection }}"
displayName: "Deploy ${{ env.name }} to ${{ region.code }}"
This parameter definition — 2 dev regions, 2 staging regions, 3 prod regions — generates 7 stages. Adding a fourth environment with two regions produces 9 stages with no template changes.
The Template Expansion Limit
Azure DevOps caps several dimensions of the expanded pipeline:
| Limit | Value |
|---|---|
| Jobs per stage | 256 |
| Total jobs in pipeline | 1,000 |
| Steps per job | 1,000 |
| Template files per pipeline | 100 |
| Expanded YAML size | 10 MB |
| Max expression length | 20,000 characters |
The 256-jobs-per-stage limit is the one nested loops most often hit. Each iteration of an inner loop that generates a job within a single stage counts toward this limit. A 3-environment × 10-region matrix at the job level generates 30 jobs per stage — well under the cap. A 20-environment × 15-region matrix generates 300 jobs per stage, and the pipeline parse fails:
The pipeline has exceeded the maximum number of generated jobs (256).
Calculate expected output before committing to nested loops: outer items × inner items = total generated stages or jobs. Leave headroom for the rest of the pipeline.
Three levels of nesting (${{ each }} inside ${{ each }} inside ${{ each }}) hits template expansion timeouts before the item count limit. Four levels is not a reliable pattern in production pipelines.
Flattening Deep Nesting
When the data is naturally hierarchical (tier → region → service), the loop representation matches the data shape. The execution result is a flat list of independent stages. Pre-compute that flat list in the parameter definition and replace deep nesting with a single ${{ each }} over the pre-computed combinations.
# Before: three-level nested loop — parse-time heavy, hits expansion limits
stages:
- ${{ each tier in parameters.tiers }}:
- ${{ each region in parameters.regions }}:
- ${{ each service in parameters.services }}:
- stage: ${{ tier.name }}_${{ region.code }}_${{ service.name }}
jobs:
- deployment: Deploy
environment: ${{ tier.name }}-${{ region.code }}-${{ service.name }}
# ...
# After: single-level loop over a pre-computed flat array
parameters:
- name: deployMatrix
type: object
default:
- tier: dev
region: eastus
service: api
serviceConnection: sc-dev-eastus-api
- tier: dev
region: eastus
service: worker
serviceConnection: sc-dev-eastus-worker
- tier: dev
region: westeu
service: api
serviceConnection: sc-dev-westeu-api
- tier: prod
region: eastus
service: api
serviceConnection: sc-prod-eastus-api
- tier: prod
region: eastus
service: worker
serviceConnection: sc-prod-eastus-worker
stages:
- ${{ each item in parameters.deployMatrix }}:
- stage: ${{ item.tier }}_${{ item.region }}_${{ item.service }}
displayName: "${{ item.tier }} / ${{ item.region }} / ${{ item.service }}"
jobs:
- deployment: Deploy
environment: ${{ item.tier }}-${{ item.region }}-${{ item.service }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Deploying ${{ item.service }} to ${{ item.region }} (${{ item.tier }})"
displayName: "Deploy ${{ item.service }}"
The flat array approach is easier to read, easier to filter with inline ${{ if }}, and immune to the three-level nesting timeout. When the combinations are dynamic — generated from infrastructure state at runtime — a script job can write the array to a pipeline variable and pass it to the next template (covered in the Dynamic Matrix Strategies article in this series).
Advanced Iteration Patterns
Conditional Stage Dependencies from Object Properties
Each generated stage can declare dependsOn: from the iterated object, making the deployment topology data-driven rather than hardcoded in the template.
parameters:
- name: environments
type: object
default:
- name: dev_eastus
serviceConnection: sc-dev-eastus
dependsOn: [] # Runs immediately after Build
- name: dev_westeu
serviceConnection: sc-dev-westeu
dependsOn: []
- name: staging_eastus
serviceConnection: sc-staging-eastus
dependsOn:
- Deploy_dev_eastus # Waits for dev in the same region
- name: staging_westeu
serviceConnection: sc-staging-westeu
dependsOn:
- Deploy_dev_westeu
- name: prod_eastus
serviceConnection: sc-prod-eastus
dependsOn:
- Deploy_staging_eastus
- Deploy_staging_westeu # Requires both staging regions to succeed
- name: prod_westeu
serviceConnection: sc-prod-westeu
dependsOn:
- Deploy_staging_eastus
- Deploy_staging_westeu
stages:
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: ubuntu-latest
steps:
- script: echo "Build"
displayName: "Build application"
- ${{ each env in parameters.environments }}:
- stage: Deploy_${{ replace(env.name, '-', '_') }}
dependsOn: ${{ env.dependsOn }}
jobs:
- deployment: Deploy
environment: ${{ env.name }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to ${{ env.name }}"
displayName: "Deploy"
Adding a new environment requires adding one object to the array with the correct dependsOn references. The template does not change.
Generating Steps from Object Properties
${{ each }} at the step level generates validation or command steps from a data-driven list. Adding a check requires a new array entry, not a template edit. Combine with inline ${{ if step.enabled }} to make individual steps opt-in per caller.
parameters:
- name: validationSteps
type: object
default:
- name: Lint
command: npm run lint
enabled: true
- name: UnitTests
command: npm test
enabled: true
- name: IntegrationTests
command: npm run test:integration
enabled: false # Disabled by default; callers can override
- name: SecurityScan
command: npm audit --audit-level=high
enabled: true
stages:
- stage: Validate
jobs:
- job: Validation
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
- script: npm ci
displayName: "Install dependencies"
- ${{ each step in parameters.validationSteps }}:
- ${{ if eq(step.enabled, true) }}:
- script: ${{ step.command }}
displayName: ${{ step.name }}
A caller that needs integration tests passes the same array with enabled: true on that entry. The template does not change.
Using ${{ each }} with templateContext
When iterating a jobList parameter, each job carries both its standard properties and an optional templateContext object. The loop body can read both dimensions in the same expression — the caller co-locates job structure and deployment metadata; the template reads both to generate fully configured stages.
# templates/deploy-with-context.yml
parameters:
- name: jobs
type: jobList
default: []
stages:
- stage: Deploy
jobs:
- ${{ each job in parameters.jobs }}:
- deployment: ${{ job.job }}
environment: ${{ job.environment }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- ${{ if eq(job.templateContext.approvalRequired, true) }}:
- task: ManualValidation@0
displayName: "Manual approval for ${{ job.job }}"
inputs:
notifyUsers: |
ops-team@example.com
instructions: "Approve deployment to ${{ job.templateContext.region }}"
- script: |
echo "Region: ${{ job.templateContext.region }}"
displayName: "Deploy to ${{ job.templateContext.region }}"
# azure-pipelines.yml — caller co-locates job definition and deployment metadata
stages:
- template: templates/deploy-with-context.yml
parameters:
jobs:
- job: DeployEastUS
environment: prod-eastus
templateContext:
region: eastus
approvalRequired: true # Generates a ManualValidation step
- job: DeployWestEU
environment: prod-westeu
templateContext:
region: westeu
approvalRequired: false # No approval step generated
- job: DeployApSouth
environment: prod-apsouth
templateContext:
region: apsouth
approvalRequired: true
templateContext properties (job.templateContext.region, job.templateContext.approvalRequired) and standard job properties (job.job, job.environment) are all accessible in the same loop body via the same dot-notation. This is the pattern that makes templates genuinely generic: the caller owns the data, the template owns the logic.
Hands-On Example: Multi-Region, Multi-Tier Deployment Matrix
Scenario: An automation engineer needs a pipeline that deploys a microservice to three tiers (dev, staging, prod) across two regions (eastus, westeu) — six deployment stages total. Each tier-region combination has a unique service connection and Azure environment. Prod stages require blue-green deployment; dev and staging use rolling. The pipeline must support adding a third region (apsouth) by editing only the parameter definition — zero template changes.
Prerequisites:
- Six Azure DevOps environments named by convention:
dev-eastus,dev-westeu,staging-eastus,staging-westeu,prod-eastus,prod-westeu - Six service connections named by convention:
sc-dev-eastus,sc-dev-westeu, etc. - A build stage that produces a deployable artifact
Step 1: Create the deployment matrix template
# templates/deploy-matrix.yml
parameters:
- name: environments
type: object
default: []
stages:
- ${{ each env in parameters.environments }}:
- stage: ${{ replace(env.tier, '-', '_') }}_${{ replace(env.region, '-', '_') }}
displayName: "${{ env.tier }} (${{ env.region }})"
dependsOn: ${{ env.dependsOn }}
jobs:
- deployment: Deploy
# Azure DevOps environment uses hyphen convention; stage name uses underscore
environment: ${{ env.tier }}-${{ env.region }}
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
# Prod uses blue-green; dev and staging use rolling
- ${{ if eq(env.blueGreen, true) }}:
- script: |
echo "Blue-green deployment: ${{ env.tier }}-${{ env.region }}"
echo "Service connection: ${{ env.serviceConnection }}"
displayName: "Blue-green deploy"
- script: echo "Swap complete: ${{ env.tier }}-${{ env.region }}"
displayName: "Verify slot swap"
- ${{ else }}:
- script: |
echo "Rolling deployment: ${{ env.tier }}-${{ env.region }}"
echo "Service connection: ${{ env.serviceConnection }}"
displayName: "Rolling deploy"
- script: echo "Deployment complete: ${{ env.tier }}-${{ env.region }}"
displayName: "Post-deploy verification"
Step 2: Create the calling pipeline
# azure-pipelines.yml
trigger:
- main
parameters:
- name: environments
type: object
default:
# dev — runs after Build, no environment gate
- tier: dev
region: eastus
serviceConnection: sc-dev-eastus
blueGreen: false
dependsOn:
- Build
- tier: dev
region: westeu
serviceConnection: sc-dev-westeu
blueGreen: false
dependsOn:
- Build
# staging — depends on the matching dev region
- tier: staging
region: eastus
serviceConnection: sc-staging-eastus
blueGreen: false
dependsOn:
- dev_eastus
- tier: staging
region: westeu
serviceConnection: sc-staging-westeu
blueGreen: false
dependsOn:
- dev_westeu
# prod — requires both staging regions to succeed before deploying
- tier: prod
region: eastus
serviceConnection: sc-prod-eastus
blueGreen: true
dependsOn:
- staging_eastus
- staging_westeu
- tier: prod
region: westeu
serviceConnection: sc-prod-westeu
blueGreen: true
dependsOn:
- staging_eastus
- staging_westeu
stages:
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: ubuntu-latest
steps:
- script: echo "Building application"
displayName: "Build"
- publish: $(Build.ArtifactStagingDirectory)
artifact: app
- template: templates/deploy-matrix.yml
parameters:
environments: ${{ parameters.environments }}
Step 3: Verify the Expanded YAML
Open the pipeline in Azure DevOps, select Run pipeline, then choose Download full YAML (or use the CLI: az pipelines run --dry-run). The Expanded YAML should show six deployment stages named with the tier-region underscore pattern.
Confirm:
- Each stage’s
environment:uses hyphen notation (prod-eastus) - Each stage’s
stage:key uses underscore notation (prod_eastus) - Prod stages include the blue-green deploy step; dev and staging stages include rolling only
- Prod stages list both staging regions in
dependsOn:
Step 4: Add a third region
Add three entries to the environments array — one per tier for apsouth:
- tier: dev
region: apsouth
serviceConnection: sc-dev-apsouth
blueGreen: false
dependsOn:
- Build
- tier: staging
region: apsouth
serviceConnection: sc-staging-apsouth
blueGreen: false
dependsOn:
- dev_apsouth
- tier: prod
region: apsouth
serviceConnection: sc-prod-apsouth
blueGreen: true
dependsOn:
- staging_apsouth
- staging_eastus
- staging_westeu
Re-run the pipeline. The Expanded YAML now shows nine deployment stages. The template file has not changed.
Best Practices and Optimization
Use compound identifiers for generated names. Single-property stage names (Deploy_${{ env.region }}) collide when two items share the same property value. Compose names from at least two properties (${{ env.tier }}_${{ env.region }}) to guarantee uniqueness as the array grows.
Replace three-level nesting with a pre-computed flat array. A nested loop over 5 × 5 × 5 items generates 125 stages — within the 10 MB expanded YAML limit, but prone to parse timeouts. A single loop over a 125-item flat array is faster to expand and easier to filter.
Leverage ${{ replace() }} for sanitization. Stage IDs only accept [A-Za-z0-9_]. Use ${{ replace(item.name, '-', '_') }} inside your template to prevent parse errors when callers pass names with hyphens.
Place inline ${{ if }} filters in the template, not the caller. The caller provides the full set of environments; the template decides which ones generate stages. This keeps filter logic centralized and prevents callers from submitting incorrect pre-filtered arrays.
Document the expected object shape in the default: block. YAML parameters have no schema enforcement. A missing property resolves to empty string silently. The default: block is the authoritative example of the required object shape; callers should copy and modify it.
Minimize template file calls inside loops. Each template: reference inside a loop body triggers one repository fetch per iteration. A loop over 50 environments that calls a three-file template chain triggers 150 repository fetches during parse. Consolidate logic into the loop body to reduce parse-time overhead.
Never use $(var) or $[var] inside ${{ each }}. Runtime variables do not exist during template expansion. Referencing them inside a loop body produces empty string, not an error — the pipeline runs but generates stages with missing values.
Troubleshooting Common Issues
Duplicate identifier parse error on generated stages
Two items in the array produce the same rendered stage name after property substitution. Add a second property to the stage name: ${{ item.tier }}_${{ item.region }}. Add a comment in the default: block warning callers that tier-region combinations must be unique across the array.
The pipeline has exceeded the maximum number of jobs
The loop generates more than 256 jobs per stage or more than 1,000 total jobs. Count the expected output: outer items × inner items = generated stages or jobs. If the count approaches 256, flatten nested loops into a pre-computed flat array and use an inline ${{ if }} to filter out combinations not needed for the current run.
A property accessed via ${{ item.prop }} resolves to empty string for some iterations
The property is absent from those items — YAML object arrays do not enforce a schema. Use ${{ coalesce(item.prop, 'defaultValue') }} for optional properties. For required properties, add a validation step that fails the pipeline when the property is missing:
- ${{ each env in parameters.environments }}:
- stage: ${{ replace(env.tier, '-', '_') }}_${{ replace(env.region, '-', '_') }}
jobs:
- job: Validate
pool:
vmImage: ubuntu-latest
steps:
# Evaluated at compile-time; if env.serviceConnection is missing, the script is inserted
- ${{ if eq(env.serviceConnection, '') }}:
- script: |
echo "##vso[task.logissue type=error]Missing required property: serviceConnection on ${{ env.tier }}_${{ env.region }}"
exit 1
displayName: "Validate required properties"
Stage name contains invalid characters — parse error
A property value used in the stage name contains a hyphen, dot, space, or other character outside [A-Za-z0-9_]. Stage IDs also cannot start with a number. Use ${{ replace(env.name, '-', '_') }} to sanitize the string.
Inner loop binding name returns empty string — outer binding name was reused
The inner loop binding name shadows the outer loop binding name. Use distinct, semantically meaningful names: ${{ each tier in parameters.tiers }} for the outer, ${{ each region in tier.regions }} for the inner. Never reuse a binding name across nested levels.
Mapping iteration order is inconsistent between runs
YAML mapping key order is implementation-defined; Azure DevOps sorts mapping keys alphabetically. If iteration order matters (e.g., for a stage dependency chain), switch from a mapping to a sequence of objects with an explicit dependsOn: property on each item.
Key Takeaways
${{ each }}has two forms: array iteration binds to the element; mapping iteration binds topair.keyandpair.value. Using the wrong form produces silent empty bindings, not an error.- Object array iteration is the most flexible pattern — each item carries multiple properties accessible via
item.propertyName, enabling multi-dimensional stage generation from a single compact parameter. - You can sanitize Stage IDs using the
${{ replace() }}function at compile-time. - Nested
${{ each }}is valid to two levels. At three levels, calculate expected output counts against the 256-job and 1,000-job limits before committing. - Inline
${{ if }}filtering inside a loop body moves filter logic into the template and away from the caller — the caller defines the full set of environments; the template decides which ones generate stages. - Pre-computed flat arrays beat deep nesting in every dimension: easier to read, easier to filter, faster to parse, and immune to the expansion limit.
Next Steps:
- Read the Dynamic Matrix Strategies article in this series to combine
${{ each }}with pattern that pairs${{ each job in parameters.jobs }}with per-item metadata to drive environment routing and conditional step injection - Audit existing
${{ each }}loops in your templates and verify that generated stage names cannot collide when new items are added to the parameter array
