Advanced each Looping: Iterating Complex Nested Objects

Apr 29, 2026 min read

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 what key and value refer 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.

SLtoaardtYPAiMpLeCE$UGEl&ov{Snevima{$arnEanPpl{noexAleaiue{ilresuErlaatlacsaxRaetciituiteum-ehfzietgecFneTTendinuitiEicrt:o$tnemxtouIont(eirepeneDEovssrmdsSxPaShEeitphArtxsit(aaagsepsnirtnse)paioeiden/snodnpcet$snalds[ist}aYvoa}cAYan}eMAr})LMsL]||FalseSIktiepm

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

CriterionUse a mappingUse 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:

LimitValue
Jobs per stage256
Total jobs in pipeline1,000
Steps per job1,000
Template files per pipeline100
Expanded YAML size10 MB
Max expression length20,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

  1. ${{ each }} has two forms: array iteration binds to the element; mapping iteration binds to pair.key and pair.value. Using the wrong form produces silent empty bindings, not an error.
  2. 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.
  3. You can sanitize Stage IDs using the ${{ replace() }} function at compile-time.
  4. 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.
  5. 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.
  6. 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

Sources