The step is in the YAML. The variable is set. The log shows the pipeline ran without errors. But the step never executed — no skip message, no failure, just nothing. You have just met the silent failure.
${{ if }} is the most-used expression in Azure DevOps YAML and also the most misunderstood. Engineers write a condition, the pipeline accepts it without complaint, and the block is silently excluded because the variable tested against did not exist when Azure DevOps evaluated it. Understanding why requires knowing exactly when that evaluation happens, which is long before any agent starts.
This article covers:
- A mental model of why
${{ if }}blocks go silent during the compile phase. - A two-minute diagnostic process using the Expanded YAML view to catch hidden errors.
- Four refactoring patterns to move logic to the correct evaluation phase.
- A decision table for choosing the right gate based on your variable source.
- A checklist for auditing pipelines before silent failures reach production.
Why ${{ if }} Goes Silent
Where in the Pipeline Lifecycle ${{ }} Evaluates
Azure DevOps evaluates ${{ }} expressions during the Compile phase (Template Expansion) — before a run is queued, before an agent is assigned, and before any runtime variable values exist. The pipeline moves through five phases sequentially, and the ${{ }} evaluation window closes in Phase 2:
| Phase | Name | What happens | Expression syntax |
|---|---|---|---|
| 1 | Parse | YAML file loaded, syntax validated | — |
| 2 | Compile / Expand | ${{ }} evaluated, templates inlined | ${{ }} only |
| 3 | Queue | Pipeline queued, run record created | — |
| 4 | Initialize | Agent starts, variables hydrated | $( ) and $[ ] |
| 5 | Execute | Tasks and scripts run | $( ), $[ ], outputs |
Diagram: The Expression Evaluation Lifecycle
This diagram visualizes the “availability gap” between compile-time expansion and runtime execution. If a variable is only set in Phase 4 (Initialize), a Phase 2 (${{ if }}) condition referencing it will always see an empty string.
Visual Notes:
- The Gap: The orange zone (Phase 2) is where structural YAML changes happen. The blue zone (Phase 4) is where most dynamic data arrives.
- Silent Failure: If you use
${{ if }}to test a variable from the blue or green zones, it fails silently in the orange zone because the value is currentlynullor''.
Only two variable sources exist during Phase 2: template parameters: values and variables declared with a static literal value directly in the YAML file. Everything else arrives in Phase 4 or later.
| Available at Compile Time (Phase 2) | NOT Available at Compile Time |
|---|---|
parameters.runSecurityScan | variables['Build.Reason'] |
parameters.environment | variables['Build.SourceBranch'] |
variables.staticVar (literal in YAML) | variables['featureFlag'] (from variable group) |
variables.templateVar (template parameter) | variables['System.PullRequest.TargetBranch'] |
| — | variables['myOutputVar'] (task output) |
| — | Any queue-time override value |
If an ${{ if }} condition references anything in the right column, it receives an empty string. Since an empty string is falsy, the block is removed from the pipeline plan entirely before the agent ever sees it.
What “Evaluates to Null” Actually Means
When ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }} evaluates during Phase 2, the expression engine looks up variables['Build.SourceBranch']. Because the run has not been queued, this value does not exist. The lookup returns an empty string ''.
In Azure DevOps expression evaluation, null, 0, false (boolean), and '' (empty string) are falsy. An empty string makes the if condition false. The YAML engine removes the block from the plan as if it were never written.
The system does not log a warning. From its perspective, the condition was simply false. The resulting Expanded YAML contains no trace of the excluded block:
# Source YAML
steps:
- script: npm ci
displayName: "Install dependencies"
# This block ALWAYS evaluates to false at compile time.
# Build.SourceBranch resolves to '' (empty string) during Phase 2.
- ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}:
- script: ./deploy.sh
displayName: "Deploy to production"
- script: npm test
displayName: "Run tests"
# Expanded YAML (what the agent receives)
steps:
- script: npm ci
displayName: "Install dependencies"
# The deployment step is absent — removed during compile.
- script: npm test
displayName: "Run tests"
There is one additional trap: the boolean string problem. A variable declared with false in a YAML file is stored as the string 'false' — a non-empty string, which is truthy.
variables:
isProd: false # Stored as the string 'false'
steps:
# WRONG: implicit truthiness check — 'false' is a non-empty string, so this block IS included
- ${{ if variables.isProd }}:
- script: echo "This step RUNS even when isProd is 'false'"
# CORRECT: explicit equality check handles the string representation
- ${{ if eq(variables.isProd, 'true') }}:
- script: echo "This step correctly skips when isProd is 'false'"
Always use explicit equality comparisons (eq(), ne()) on variable values.
The Three Most Common Triggers
These three patterns account for the majority of silent failures in production pipelines:
- Predefined pipeline variables:
Build.Reason,Build.SourceBranch, andSystem.PullRequest.TargetBranchare set by the service during Phase 4. They are empty strings at compile time. - Variable group values: Variable groups load during the Initialize phase (Phase 4). Any
${{ if }}testing a variable group value is testing an empty string. - Task output variables: Task output variables are set by
task.setvariableduring Phase 5 (Execute) — three full phases after${{ }}evaluates. There is no mechanism that makes a task output available at compile time.
Diagnosing the Failure in Two Minutes
The Expanded YAML View
Every completed pipeline run exposes the fully compiled YAML. To access it:
- Open the pipeline run in Azure DevOps.
- Click the job name in the left-side job list.
- Expand the Initialize job log step.
- Scroll to the Expand YAML section (or click Download full YAML from the run’s
...menu).
The Expanded YAML shows the static pipeline plan that the agent received. If a step is absent from this output, a false compile-time condition excluded it. If the block is present but did not execute, the failure is a runtime condition or a task error.
When scanning the Expanded YAML, search for the displayName: value of the missing step. If it does not appear, the step was excluded by a false ${{ if }}. If it appears but has condition: false or shows as skipped in the log, the issue is in the runtime layer.
The Compile-Time Probe Pattern
Add a temporary diagnostic step immediately before the suspicious ${{ if }} block. The probe must use $( ) macro syntax (runtime) — not ${{ }} (compile-time) — to read the variable value.
# Diagnostic probe: use $( ) to see runtime values
steps:
- script: |
echo "--- Variable availability probe ---"
echo "Build.Reason: $(Build.Reason)"
echo "Build.SourceBranch: $(Build.SourceBranch)"
echo "featureFlag: $(featureFlag)"
echo "---"
displayName: "Diagnostic: variable availability probe"
# The suspicious block follows the probe
- ${{ if eq(variables['Build.Reason'], 'PullRequest') }}:
- script: npm run security-scan
displayName: "SAST security scan"
If the probe shows the expected value (e.g., Build.Reason: PullRequest) but the step is absent from the Expanded YAML, you have confirmed the phase mismatch.
Reading the Pipeline Initialization Log
The Initialize job log entry lists every variable visible to the agent at startup. Scan the list:
- If your variable appears with a value, it exists at runtime but was not available at compile time. Refactor to a runtime condition.
- If your variable does not appear, it was never set. Check variable group bindings or upstream job outputs.
- Secret variables appear as
***.
The initialization log proves the variable arrived; the Expanded YAML proves whether the step survived compilation.
Refactoring Patterns — Moving Logic to the Right Phase
Pattern A — Replace ${{ if }} with a Runtime condition:
For step-level and job-level branching that depends on runtime values, condition: is the direct replacement. The step stays in the Expanded YAML, but the agent skips it at execution time if the condition evaluates to false.
# Before: compile-time gate (broken for runtime variables)
steps:
- ${{ if eq(variables['Build.Reason'], 'PullRequest') }}:
- script: npm run security-scan
displayName: "SAST security scan (PR only)"
# After: runtime gate (fixed)
steps:
- script: npm run security-scan
displayName: "SAST security scan (PR only)"
condition: eq(variables['Build.Reason'], 'PullRequest')
This improves observability. A non-PR run logs the step as “Step skipped” and prints the evaluated condition. Compile-time exclusions leave no evidence.
Pattern B — Promote Runtime Values to Compile-Time Parameters
When branching logic must happen at compile time (e.g., including an entire template file), the value must come from a parameters: declaration, not a variable. Queue-time parameters are available during Phase 2.
parameters:
- name: runSecurityScan
type: boolean
default: false
steps:
- script: npm ci
displayName: "Install dependencies"
# Compile-time gate: valid because parameters exist in Phase 2
- ${{ if parameters.runSecurityScan }}:
- template: templates/security-scan.yml
Use type: boolean for parameters. Unlike string variables, typed boolean parameters preserve their type and avoid the string truthiness trap.
Pattern C — Split the Condition into Two Phases
Use ${{ if parameters.enableStep }} as a compile-time inclusion gate, and condition: as the runtime execution gate. The two are independent.
parameters:
- name: enableSecurityScan
type: boolean
default: true # Caller can opt out
steps:
# Phase 1: Compile-time gate (include step if caller opts in)
- ${{ if parameters.enableSecurityScan }}:
# Phase 2: Runtime gate (execute only on PR runs)
- script: npm run security-scan
displayName: "SAST security scan (PR only)"
condition: eq(variables['Build.Reason'], 'PullRequest')
A caller passing enableSecurityScan: false removes the step from the pipeline plan completely. A caller using the default includes the step, which then evaluates the runtime condition.
Pattern D — Choosing the Right Gate
The correct gate depends on where the controlling value originates:
| Available at Compile Time (Phase 2) | NOT Available at Compile Time |
|---|---|
parameters.myParam | Compile-time ${{ if }} |
Static YAML variables: | Compile-time ${{ if }} |
| Variable group value | Runtime condition: |
| Predefined pipeline variable | Runtime condition: |
| Task output variable | Runtime condition: |
If the value can change between pipeline runs or cannot be known before the run starts, it belongs in a runtime condition:, never a compile-time ${{ if }}.
Output Variables — The Special Case
Why ${{ if }} Can Never Read a Task Output
Task output variables are set during Phase 5 (Execute). It is physically impossible for a compile-time expression (Phase 2) to read them.
The correct pattern uses a runtime condition: referencing the dependency context:
jobs:
- job: CheckJob
steps:
- script: echo "##vso[task.setvariable variable=runTests;isOutput=true]true"
name: setFlag
- job: TestJob
dependsOn: CheckJob
steps:
# CORRECT: condition: fires at runtime and reads the output variable
- script: npm test
displayName: "Tests"
condition: eq(dependencies.CheckJob.outputs['setFlag.runTests'], 'true')
Cross-Stage Output Variables
Stage-level output variables use $[ stageDependencies.StageName.JobName.outputs['stepName.varName'] ]. The consuming stage must declare dependsOn: StageName to populate the dependency object.
A missing dependsOn causes stageDependencies to be empty. The variable reference resolves to '', and the step skips silently on every run — a different root cause producing the same symptom as the compile-time problem. Use the Expanded YAML and the diagnostic probe to confirm.
Hands-On Example: Fixing a Branch-Gated Security Scan
Scenario: A pipeline includes a SAST security scan that should run only on pull requests. An engineer wrote the condition using ${{ if eq(variables['Build.Reason'], 'PullRequest') }}. The scan never runs, even on PRs. No error appears in the logs.
Prerequisites:
- An Azure DevOps pipeline
- Ability to trigger a manual run and a PR-based run
Step 1: Confirm the failure with the Expanded YAML
Trigger a PR-based run. Open the run, click the job, and expand Initialize job → Expand YAML. Search for SAST security scan.
The step is absent. A false compile-time ${{ if }} excluded it.
Step 2: Add the diagnostic probe
steps:
- script: echo "Build.Reason: $(Build.Reason)"
displayName: "Diagnostic: confirm Build.Reason value"
- ${{ if eq(variables['Build.Reason'], 'PullRequest') }}:
- script: npm run security-scan
displayName: "SAST security scan"
Re-run the pipeline. The probe shows Build.Reason: PullRequest. The variable exists at runtime, but the step is still absent from the Expanded YAML. The phase mismatch is confirmed.
Step 3: Refactor to a runtime condition:
# BEFORE
- ${{ if eq(variables['Build.Reason'], 'PullRequest') }}:
- script: npm run security-scan
displayName: "SAST security scan"
# AFTER
- script: npm run security-scan
displayName: "SAST security scan"
condition: eq(variables['Build.Reason'], 'PullRequest')
Step 4: Verify on both trigger types
Trigger a direct push. The Expanded YAML includes the scan step. The run log shows SAST security scan — Step skipped because the condition evaluated to false.
Trigger a PR run. The Expanded YAML includes the scan step. The run log shows the step executing correctly.
The silence is gone. Pipeline behavior is now auditable and debuggable.
Best Practices and Optimization
Default to runtime condition: for step branching. A condition-gated step is always visible in the Expanded YAML and produces a “Step skipped” log entry. Compile-time exclusions leave no evidence.
Reserve ${{ if }} for structural changes. Use it for including template files, adding entire jobs, or generating matrices. These require Phase 2 decisions.
Audit every ${{ if variables[...] }} in your pipeline. If the variable is not in your static YAML or parameters: block, it evaluates to empty string at compile time. This is almost always a bug.
Use typed boolean parameters. This avoids the “boolean string” trap where the string 'false' evaluates as truthy.
Check the Expanded YAML first. It is the fastest way to distinguish between a compile-time exclusion and a runtime skip. Do not proceed with other debugging until you check the Expanded YAML.
Troubleshooting Common Issues
Condition works on manual runs but fails on PR-triggered runs
A compile-time ${{ if parameters.myParam }} uses a parameter with a default that works in manual runs but is not passed by the PR trigger. Set a sensible default in the parameters: block and confirm the PR trigger policy isn’t overriding it with an empty value.
condition: eq(variables['Build.Reason'], 'PullRequest') skips the step even on a PR
The pipeline was triggered by a pull request completion event, not a pull request validation trigger. The Build.Reason value for completion is IndividualCI. Use the in() function:
condition: in(variables['Build.Reason'], 'PullRequest', 'IndividualCI')
${{ elseif }} and ${{ else }} blocks are also missing from Expanded YAML
If an if branch references a runtime variable (evaluating to false), the elseif evaluates. If the else branch executes but its contents also reference runtime variables in nested conditions, it produces the same silent failure. Trace each branch independently in the Expanded YAML.
A static YAML variable used in ${{ if }} evaluates as truthy when it should be falsy
The variable is defined as false (YAML boolean), which is stored as the string 'false'. Any non-empty string is truthy. Use an explicit equality check:
# Correct
- ${{ if eq(variables.isProd, 'true') }}:
Key Takeaways
${{ if }}evaluates before the pipeline is queued. Predefined variables, variable group values, and task outputs do not exist yet. Conditions referencing them are always false.- The Expanded YAML view distinguishes a compile-time exclusion (step is absent) from a runtime skip (step is present with a
condition:). - The default refactoring path removes the
${{ if }}wrapper and adds acondition:field on the step. This keeps the step in the plan and makes skip behavior visible in the run log. - Reserve
${{ if }}for values that exist at compile time: templateparameters:and static YAMLvariables:. - Task output variables can never be read by
${{ if }}— use a runtimecondition:instead.
Next Steps:
- Apply the Expanded YAML diagnostic to your pipelines to find silently broken conditions already in production
- Read the Ultimate Guide to Azure DevOps YAML Expressions (Pillar Post) for a full treatment of variable availability
- Read the Cross-Job Communication article for the complete output variable dependency syntax
