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
- ${{ 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)
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)
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
- Schema Consistency is Vital: Use objects to keep related data together and reduce parameter drilling.
- Modularize for Scale: If you hit the 100-include limit, bring your loop logic inline or consolidate templates.
- Matrix for Parallelism: Use
eachto change what runs, but usematrixto change how many run. - Unique IDs: Always use
format()to generate unique, valid stage and job identifiers within your loops.
