You’ve spent two hours debugging why your pipeline skipped a critical deployment stage. The logs show the variable was set, the logic seems correct, yet the condition evaluated to false. Welcome to the “Expression Gap”—the confusing territory where Azure DevOps evaluates three different syntaxes (${{ }}, $[ ], and $( )) at different times.
Azure DevOps YAML pipelines are not just configuration files; they are executable programs. The expression engine is the logic core that determines whether a step runs, how a job scales, and which environment receives a build. However, official documentation often treats these syntaxes as interchangeable, leading to “silent failures” where compile-time logic tries to read runtime data. This guide demystifies the Azure DevOps expression engine, covering evaluation order, lifecycle phases, and the architectural patterns required to build pipelines that never fail a condition due to syntax confusion.
1. The Trinity of Syntaxes: ${{ }}, $[ ], and $( )
To master Azure DevOps logic, you must distinguish between the three primary evaluation syntaxes. Each operates in a specific phase of the pipeline lifecycle and has a distinct scope of visibility.
The Trinity of Syntaxes: Evaluation Timeline
1.1: Compile-time Expressions (${{ }})
Compile-time expressions are evaluated during the “Expansion” phase, before the pipeline actually starts running. This syntax is primarily used for template meta-programming, such as ${{ if }}, ${{ each }}, and templateContext.
Because evaluation happens before an agent is assigned, ${{ }} expressions can modify the physical structure of the pipeline—adding or removing entire jobs or steps. However, they are blind to the future. They cannot see variables produced by previous steps or jobs because those data points do not exist during the parsing phase. If you attempt to check a runtime output variable using ${{ }}, the expression will evaluate to null, and your logic block will simply vanish from the execution plan.
1.2: Runtime Expressions ($[ ])
Runtime expressions are evaluated during the “Execution” phase, specifically at the start of a job or stage. This is the standard syntax for condition: properties and dynamic variable mapping. Unlike compile-time expressions, runtime expressions can access “Output Variables” from previous jobs via the dependencies context.
The architectural trade-off is structural: runtime expressions are more flexible than compile-time logic, but they cannot change the structure of the pipeline. You can use $[ ] to skip a job, but you cannot use it to dynamically add a job that wasn’t already in the plan.
1.3: Macro Expressions ($( ))
Macro expressions are the “legacy” syntax inherited from Classic Pipelines. They are evaluated just before a specific task runs via standard string interpolation. While useful for passing values into task inputs or script blocks, they are essentially a “search and replace” operation.
Security Warning: Macros are susceptible to injection attacks if used improperly in scripts. If a user-provided variable containing shell characters (like ; or &) is expanded via $( ) directly into a bash task, it can lead to unauthorized command execution.
2. The Pipeline Lifecycle: When Does Logic Happen?
Understanding the “When” is as important as the “How.” Azure DevOps processes your YAML in three distinct phases.
Pipeline Lifecycle: When Logic Happens
2.1: The Three Phases of Evaluation
- Parsing & Expansion: The orchestrator reads your YAML, follows every template link, and evaluates all
${{ }}blocks. This creates one massive “Expanded YAML” file. If a condition is false here, the code is deleted from the plan. - Planning: The engine looks at
condition:blocks (including those using$[ ]) to determine which jobs and stages are eligible to run based on the results of previous dependencies. - Execution: Tasks are sent to the agent. Just before a task starts,
$( )macros are expanded. During task execution, the shell processes its own environment variables (e.g.,$MY_VAR).
2.2: The Variable Expansion Order
A common source of “Silent Failures” is misunderstanding variable visibility. Variables defined in a YAML variables: block are not always available in a ${{ if }} statement. The hierarchy flows from System Variables and Template Parameters (visible at compile-time) down to Task Output Variables (visible only at runtime via $[ ]). If your logic depends on a value set by a script, you must use runtime syntax.
3. Advanced Conditional Insertion
Mastering conditions allows you to build a “single pipeline” that handles multiple complex scenarios.
3.1: Template-Level if/elseif/else
Use ${{ if }} to toggle entire stages or jobs based on a parameters object. A common pattern is the “Standard vs. Premium” path. For example, you can conditionally insert an expensive security scanning stage only if a runFullScan parameter is set to true. Because this uses compile-time logic, the “Standard” run won’t even show the security stage as “skipped”—it simply won’t exist in the UI, reducing log noise.
3.2: Job and Step Conditions
For logic that depends on the success or failure of previous work, master the condition: property using $[ ]. Always prefer built-in functions like succeeded(), failed(), or always() over manual status checks. For instance, a cleanup job should use condition: always() to ensure it runs regardless of whether the build succeeded, whereas a deployment should use condition: succeeded() to prevent pushing broken code.
4. Template Meta-Programming
In 2026, the most advanced pipelines are “data-driven,” using parameters to generate structure.
4.1: The Power of ${{ each }}
The ${{ each }} keyword allows you to iterate through arrays and objects to generate dynamic jobs. Instead of copy-pasting the same deployment logic for 10 different Azure regions, you can define a list of regions in your parameters and use a single loop to generate 10 unique, parallel jobs. This reduces maintenance toil and ensures that a change to your deployment logic is automatically applied to every region.
4.2: Encapsulation with templateContext
templateContext is a specialized property for passing complex metadata into jobList or stageList templates without breaking the Azure DevOps schema. It allows you to “smuggle” custom properties—like a security tier or a cost center—alongside your jobs. Your template can then use ${{ each }} to iterate through the jobs and use the context to drive conditional logic, such as injecting mandatory audit steps only for “Critical” jobs.
5. The Expression Checklist: Which One Do I Use?
| Use Case | Recommended Syntax | Why? |
|---|---|---|
| Change pipeline structure | ${{ if }} | Evaluated before the plan is fixed. |
| Loop through parameters | ${{ each }} | Generates multiple jobs/steps from data. |
| Check an output variable | $[ ] | Can see data from previous jobs. |
Use in a condition: | $[ ] | The standard for runtime skipping logic. |
| Pass value to a Task input | $( ) | Standard interpolation for task configurations. |
Hands-On Example: The “Smart” Multi-Stage Pipeline
To see these syntaxes in action, consider a pipeline that must calculate a version number in a build stage and then conditionally deploy to production.
# Build Stage sets an output variable
- stage: Build
jobs:
- job: Compile
steps:
- script: echo "##vso[task.setvariable variable=isReady;isOutput=true]true"
name: CheckGate
# Deploy Stage consumes it at runtime
- stage: Deploy
dependsOn: Build
condition: eq(stageDependencies.Build.Compile.outputs['CheckGate.isReady'], 'true')
jobs:
- job: Production
steps:
- script: echo "Deploying version $(Build.BuildNumber)"
Multi-Stage Variable Mapping Flow
In this example, the condition uses runtime syntax ($[ ] is implicit in stage-level conditions) to read the isReady flag, while the final script uses macro syntax ($( )) to display the build number.
Key Takeaways
- Timing is Everything: 90% of expression failures are “timing” issues where compile-time logic tries to read runtime data.
- ${{ }} Creates the Plan: Use it for structural decisions based on parameters.
- $[ ] Executes the Plan: Use it for runtime decisions based on variables.
- Use the “Expanded YAML” View: It is your best friend for debugging why
${{ }}logic isn’t behaving as expected.
