Advanced each Looping: Iterating Through Complex Nested Objects

Apr 24, 2026 min read

You have a deployment pipeline that needs to hit 12 different Azure regions. You could copy and paste the same 50 lines of YAML 12 times—but now you have a 600-line file that is impossible to maintain. When the security team mandates a new compliance step, you have to update it in 12 places. This is the definition of “DevOps Debt.”

The ${{ each }} keyword is the primary tool for “Meta-Programming” in Azure DevOps. It allows you to transform data (parameters) into pipeline structure (jobs and stages). While simple string loops are common, most enterprise requirements involve complex nested objects—such as a list of environments, each containing a list of regions, each with a specific VM SKU. Maneuvering these objects without hitting the “100-level nesting” cap or causing significant “Initialization Lag” requires a specific set of architectural patterns. This guide teaches you how to master the each keyword for large-scale automation, from object-based looping to conditional filtering.

1. The Basics of Object-Based Looping

To move beyond simple string arrays, you must define your parameters as rich object lists. This allows you to bundle all related configuration for a specific deployment target into a single unit of data.

1.1: Moving Beyond String Arrays

Instead of a simple list like ['eastus', 'westus'], define a schema that carries the context your tasks need.

parameters:
- name: regions
  type: object
  default:
    - name: 'eastus'
      sku: 'Standard_D2'
    - name: 'westus'
      sku: 'Standard_D4'

You iterate through this list using ${{ each region in parameters.regions }}. Inside the loop, the variable region represents the current object in the array.

1.2: Accessing Nested Properties

Access properties using standard dot notation: ${{ region.sku }}. For optional properties, always use the coalesce() function to provide a safe fallback. This prevents “Null Reference” errors during the YAML expansion phase if a specific region object is missing a field. vmSize: ${{ coalesce(region.sku, 'Standard_B2s') }}.

2. Patterns for Multi-Stage Dynamic Layouts

The power of ${{ each }} is most apparent when generating the top-level structure of your pipeline.

2.1: The Environment -> Region Pattern

Nested loops allow you to generate a 3nd-tier deployment layout (e.g., Dev -> Test -> Prod) where each environment contains multiple regions.

Nested each Loop Expansion

[1.YAeMaLchCOeDnEv]inEnvs23[..Ee[NXaaPcGmAhEeNN:SrEIeR$OgA{NT{iIEnNeNGnGevInJNvO}E.B}R_]e]$g{s{reg}}---jjjooobbb[:::EDDPXeerPvA__dNEW_DaeEEssaDttsUUtYSSUASML]
- ${{ each env in parameters.environments }}:
  - stage: Deploy_${{ env.name }}
    jobs:
    - ${{ each region in env.regions }}:
      - job: Deploy_${{ region.name }}
        steps:
        - template: deploy-tasks.yml

Architectural Warning: Deeply nested loops count toward the 100-template include limit. If your inner loop calls a template:, and you have 10 environments with 12 regions each, you will hit the limit (120 calls). For high-scale loops, define your jobs or steps inline to avoid the include cap.

2.2: Dynamic Step Generation

You can also use loops to inject a variable number of tasks based on a “metadata hint.” For instance, you may only want to inject an “App Gateway Cleanup” task if a job’s configuration object contains a gatewayId.

3. Conditional Filtering Inside Loops

Not every item in a list should result in a pipeline stage. You can use ${{ if }} inside an ${{ each }} block to selectively generate structure.

3.1: The “Skip” Pattern

This pattern allows you to maintain a global configuration list but filter it based on the current run context.

- ${{ each app in parameters.apps }}:
  - ${{ if eq(app.enabled, true) }}:
    - job: Build_${{ app.name }}

If app.enabled is false, the expansion engine simply skips that iteration, and the job never reaches the execution plan.

3.2: Filtering by Tags

Use the contains() and in() functions to filter based on metadata. A common pattern is “Tiered Deployment,” where you only generate stages for items tagged with tier: 'internal' in a specific branch, and tier: 'public' in the main branch.

4. Advanced Object Manipulation Functions

In 2026, the standard library for expressions has matured to support complex string and object handling.

4.1: Joining and Formatting

Unique identifiers are mandatory for jobs and stages. Use the format() and join() functions to generate valid YAML keys from your objects. job: Deploy_${{ region.name }}_${{ format('{0:yyyyMMdd}', pipeline.startTime) }}. Using format ensures your IDs are predictable and conform to the strict naming restrictions (A-Z, 0-9, and underscores only).

4.2: Map Looping

You can also loop through a key-value map using ${{ each pair in parameters.myMap }}. This provides access to ${{ pair.key }} and ${{ pair.value }}, which is essential for transforming dictionaries into environment variables or task arguments.

Map Looping (Keys and Values)

[{PaaAppRppA12M::ETEvvR12''M,A}P]23[..eppppaaaaaciiiihrrrr....pkkaeaeaiylylruu=e=ei==n=='='=paaapprpvpva1122m''''eters.map][RESULT]

5. Optimization: Avoiding “Initialization Lag”

Every iteration of an ${{ each }} loop adds computational overhead to the Azure DevOps orchestrator. If you use nested loops to generate 1,000 jobs, your pipeline will hang in an “Evaluating” state for 40+ seconds.

The Performance Winner: Matrix

If your jobs are identical in structure and only differ by variables, do not use each. Instead, use ${{ each }} to generate a single matrix object, then let the runtime engine handle the parallelism.

each vs. matrix (Scale Strategy)

[${E---{xpCH4eaoi0anmts([cspsUhiuIsAot1neDine0iDts0tfIeP-ioNmhAfarGaLil}sLlisS}eeztTsarE](tltuPSriicSeumotrcinu]vttreuerr))e[sR---turnCZIatoentimrs([empotUgeuasCytineL:PemtOhspfNmaaIoIasvcnrNteatiGrrtpi(ioiaJxAanarOgblaB]elsilSnetzltsaae])rtlOtiiNosLnmY)

matrix: ${{ convertToJson(parameters.regions) }}. This shifts the work from the compile-time expansion engine to the runtime execution engine, resulting in sub-5-second initialization times even for massive fan-out scenarios.

Hands-On Example: The “Global Scale” Deployer

Consider a pipeline that takes a single GlobalConfig object. By using nested each loops and the iif() function, you can transform a 20-line JSON configuration into a 3-environment, 12-region deployment plan with custom test suites for every target, all while maintaining a single 50-line YAML file.

Key Takeaways

  1. Schema Consistency is Vital: Use objects to keep related data together and reduce parameter drilling.
  2. Modularize for Scale: If you hit the 100-include limit, bring your loop logic inline or consolidate templates.
  3. Matrix for Parallelism: Use each to change what runs, but use matrix to change how many run.
  4. Unique IDs: Always use format() to generate unique, valid stage and job identifiers within your loops.

Sources