YAML Pipeline Variables: Compile-Time vs Runtime
A comprehensive guide to Azure DevOps YAML pipeline variables covering compile-time template expressions, runtime expressions, macro syntax, output variables, secret handling, and variable groups.
YAML Pipeline Variables: Compile-Time vs Runtime
Overview
Variables in Azure DevOps YAML pipelines look deceptively simple until you realize there are three distinct syntaxes, two different evaluation phases, and a handful of scoping rules that silently determine whether your pipeline works or produces an empty string. Understanding when a variable is resolved — at compile time or at runtime — is the difference between a pipeline that conditionally deploys to the right environment and one that pushes your development build to production. This article breaks down every variable mechanism, shows exactly when each one is evaluated, and gives you patterns that hold up under real-world complexity.
Prerequisites
- An Azure DevOps organization with at least one project and a YAML pipeline
- Basic YAML pipeline syntax knowledge (triggers, stages, jobs, steps)
- Familiarity with the Azure DevOps web UI for managing variable groups and pipeline settings
- A Git repository connected to Azure Pipelines
- Node.js v18+ if you want to run the scripting examples locally
The Three Variable Syntaxes
Azure DevOps pipelines have three ways to reference a variable. They look similar but behave fundamentally differently.
Template Expressions: ${{ variables.varName }}
Template expressions are evaluated at compile time, before the pipeline even starts running. The Azure DevOps server reads your YAML, resolves every ${{ }} expression, and generates the final pipeline plan. By the time an agent picks up the work, template expressions are gone — replaced with their literal values.
variables:
environment: 'production'
steps:
- script: echo "Deploying to ${{ variables.environment }}"
displayName: 'Deploy step'
After compilation, the agent sees:
steps:
- script: echo "Deploying to production"
displayName: 'Deploy step'
The string production is baked in. The agent never sees the variable reference. This matters because template expressions can do things the other syntaxes cannot: they can control pipeline structure. You can use ${{ if }} to include or exclude entire stages, jobs, or steps. You cannot do that with runtime expressions.
Macro Syntax: $(varName)
Macro syntax is evaluated at runtime, just before a task executes. The agent replaces $(varName) with the variable's current value. If the variable does not exist, the expression is left as the literal string $(varName) — it does not error, it does not expand to empty, it stays as-is.
variables:
buildConfig: 'Release'
steps:
- script: echo "Building $(buildConfig)"
displayName: 'Build step'
Macro syntax is the most common form. It works in task inputs, script steps, and display names. But it cannot be used in conditions or in structural YAML elements like stage names or template parameters.
Runtime Expressions: $[variables.varName]
Runtime expressions are evaluated at runtime but specifically during the plan phase of each job, before tasks start executing. They are primarily used in condition properties and in variable definitions that depend on other runtime values.
variables:
isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
stages:
- stage: Deploy
condition: $[eq(variables.isMain, 'True')]
jobs:
- job: DeployProd
steps:
- script: echo "Deploying to production"
The critical difference from macro syntax: runtime expressions can use functions like eq(), ne(), and(), or(), and not(). They support the same expression grammar as condition properties. If a variable referenced in a runtime expression does not exist, it evaluates to empty string, not to the literal expression text.
Compile-Time vs Runtime: When It Matters
The distinction between compile-time and runtime evaluation trips people up constantly. Here is the rule:
Compile-time (${{ }}) has access to:
- Variables defined in the YAML file itself
- Template parameters
- Predefined compile-time variables (like
Build.Reasonin some contexts) - Variable values set in the YAML — but not values from variable groups, not values set by the UI, and not values set dynamically by previous steps
Runtime ($() and $[]) has access to:
- Everything from compile-time, plus:
- Variable group values
- Variables set in the pipeline UI
- Variables set dynamically with
##vso[task.setvariable] - Output variables from previous jobs/stages
This means the following pattern will not work:
variables:
- group: my-variable-group # Contains 'deployTarget'
steps:
# BROKEN: ${{ }} cannot see variable group values at compile time
- ${{ if eq(variables.deployTarget, 'production') }}:
- script: echo "This condition is always false"
The template expression ${{ }} is resolved before the pipeline runs. Variable group values are fetched at runtime. The compile-time expression sees an empty string for deployTarget and the condition is always false. Use a runtime condition instead:
variables:
- group: my-variable-group
steps:
- script: echo "Deploying to production"
condition: eq(variables.deployTarget, 'production')
Variable Scoping
Variables in Azure Pipelines exist at four levels, and each level has different visibility rules.
Pipeline-Level Variables
Defined at the top of your YAML file. Visible to every stage, job, and step.
variables:
globalVar: 'available-everywhere'
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: echo $(globalVar) # Works
- stage: Deploy
jobs:
- job: DeployJob
steps:
- script: echo $(globalVar) # Also works
Stage-Level Variables
Defined inside a stage block. Visible only to jobs within that stage.
stages:
- stage: Build
variables:
stageVar: 'build-only'
jobs:
- job: BuildJob
steps:
- script: echo $(stageVar) # Works
- stage: Deploy
jobs:
- job: DeployJob
steps:
- script: echo $(stageVar) # Empty — not in scope
Job-Level Variables
Defined inside a job block. Visible only to steps within that job.
jobs:
- job: JobA
variables:
jobVar: 'job-a-only'
steps:
- script: echo $(jobVar) # Works
- job: JobB
steps:
- script: echo $(jobVar) # Empty — not in scope
Step-Level Variables (Environment Variables)
You can set environment variables on individual steps using the env property. These are visible only within that step.
steps:
- script: echo $MY_VAR
env:
MY_VAR: $(someVariable)
- script: echo $MY_VAR # Not set — different step
This is the recommended pattern for passing secret variables into scripts, since env mappings prevent the variable value from appearing in the process command line.
Variable Groups and Linking
Variable groups are collections of variables managed in the Azure DevOps UI (Library) or linked from Azure Key Vault. They exist outside your YAML and are fetched at runtime.
Defining and Linking a Variable Group
variables:
- group: production-config
- group: shared-secrets
- name: localVar
value: 'defined-in-yaml'
Note the syntax change. When you use variable groups, you must switch from the mapping syntax (variables: key: value) to the list syntax (variables: - name/group). You cannot mix the two.
# BROKEN: Cannot mix mapping and list syntax
variables:
localVar: 'some-value'
- group: my-group # Syntax error
# CORRECT: All list syntax
variables:
- name: localVar
value: 'some-value'
- group: my-group
Key Vault-Linked Variable Groups
When a variable group is linked to an Azure Key Vault, secrets are fetched at runtime and automatically marked as secret. You reference them exactly like any other variable:
variables:
- group: keyvault-secrets # Linked to Azure Key Vault
steps:
- script: |
echo "Using the secret in a script"
node deploy.js --token $(apiToken)
displayName: 'Deploy with secret'
The value of $(apiToken) is masked in pipeline logs. More on secret behavior below.
Secret Variables
Secret variables behave differently from regular variables in several important ways, and if you do not understand these differences, you will waste hours debugging pipelines that appear to "do nothing."
How Secrets Are Masked
Any variable marked as secret (either in the UI, via a Key Vault-linked group, or programmatically) is replaced with *** in all pipeline logs. This applies to stdout, stderr, and task output.
steps:
- script: echo $(mySecret)
displayName: 'Print secret'
Output:
***
Secrets Are Not Available in Template Expressions
This is the single most common mistake with secrets. Template expressions (${{ }}) are resolved at compile time. Secrets are fetched at runtime. Therefore:
variables:
- group: my-secrets # Contains 'dbPassword'
steps:
# BROKEN: ${{ variables.dbPassword }} is empty at compile time
- script: echo "${{ variables.dbPassword }}"
Always use macro syntax $(dbPassword) or pass secrets via env mappings.
Secrets Are Not Automatically Available as Environment Variables
Unlike regular variables, secrets are not automatically mapped to environment variables in script tasks. You must pass them explicitly:
steps:
# BROKEN: $DB_PASSWORD is not set, even though the variable exists
- script: echo $DB_PASSWORD
# CORRECT: Explicitly map the secret to an environment variable
- script: echo $DB_PASSWORD
env:
DB_PASSWORD: $(dbPassword)
Secrets Cannot Be Passed to Other Pipelines or Output Variables (by Default)
A secret variable set in one job cannot be read by a downstream job via output variable syntax unless you explicitly mark the output variable as non-secret. This is a security feature. If you set a variable with issecret=true, downstream references return empty.
# Setting a secret output variable — downstream jobs CANNOT read this
echo "##vso[task.setvariable variable=myToken;isoutput=true;issecret=true]abc123"
# Setting a non-secret output variable — downstream jobs CAN read this
echo "##vso[task.setvariable variable=buildVersion;isoutput=true]2.1.0"
Queue-Time Variables and Parameters
Queue-Time Variables
Queue-time variables are variables that a user can set or override when they manually trigger a pipeline run. You define them in the YAML with value and they can be overridden from the "Run pipeline" dialog.
variables:
- name: deployEnvironment
value: 'staging'
When someone clicks "Run pipeline" in the UI, they can change deployEnvironment to production. However, queue-time variables are just regular variables — they have no type checking, no validation, and no dropdown.
Parameters (The Better Option)
Parameters are the modern replacement for queue-time variables. They support types, default values, and constrained value lists.
parameters:
- name: environment
displayName: 'Target Environment'
type: string
default: 'staging'
values:
- development
- staging
- production
- name: runTests
displayName: 'Run test suite'
type: boolean
default: true
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: echo "Target: ${{ parameters.environment }}"
- script: echo "Running tests"
condition: eq('${{ parameters.runTests }}', 'True')
Parameters are resolved at compile time. This means you can use them in ${{ if }} blocks to control pipeline structure. They show up as proper form fields in the "Run pipeline" dialog with dropdowns, checkboxes, and validation.
Key difference: Parameters are compile-time only. You access them with ${{ parameters.name }}, never with $(name) or $[name]. If you try to access a parameter with macro syntax, you get the literal string.
Setting Variables Dynamically with Logging Commands
The ##vso[task.setvariable] logging command lets you set variables from within a script step. This is how you pass data computed during pipeline execution to subsequent steps and jobs.
Setting a Variable for Subsequent Steps (Same Job)
steps:
- script: |
VERSION=$(node -p "require('./package.json').version")
echo "##vso[task.setvariable variable=appVersion]$VERSION"
displayName: 'Read package version'
- script: echo "Building version $(appVersion)"
displayName: 'Use computed version'
Setting a Variable for Subsequent Jobs (Output Variable)
To pass a variable to another job, you must add isoutput=true and give the step a name:
jobs:
- job: Compute
steps:
- script: |
echo "##vso[task.setvariable variable=version;isoutput=true]2.5.0"
name: versionStep
- job: Use
dependsOn: Compute
variables:
computedVersion: $[ dependencies.Compute.outputs['versionStep.version'] ]
steps:
- script: echo "Version is $(computedVersion)"
Setting a Variable for Subsequent Stages
Cross-stage output variables follow the same pattern but with a different reference path:
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: |
echo "##vso[task.setvariable variable=imageTag;isoutput=true]sha-$(Build.SourceVersion)"
name: setTag
- stage: Deploy
dependsOn: Build
variables:
imageTag: $[ stageDependencies.Build.BuildJob.outputs['setTag.imageTag'] ]
jobs:
- job: DeployJob
steps:
- script: echo "Deploying image tag $(imageTag)"
Note the difference: within a stage, you use dependencies.JobName.outputs[...]. Across stages, you use stageDependencies.StageName.JobName.outputs[...].
Predefined Variables
Azure DevOps sets dozens of variables automatically. Here are the ones I use constantly:
| Variable | Example Value | When I Use It |
|---|---|---|
Build.SourceBranch |
refs/heads/main |
Conditional deployment to prod |
Build.SourceBranchName |
main |
Tagging Docker images |
Build.BuildId |
1847 |
Unique build identifier |
Build.BuildNumber |
20260208.3 |
Version stamping |
Build.Repository.Name |
my-service |
Multi-repo pipeline logic |
Build.Reason |
PullRequest |
Skip deploy on PR builds |
System.PullRequest.TargetBranch |
refs/heads/main |
PR target branch checks |
System.PullRequest.PullRequestId |
4521 |
Commenting on PRs |
Agent.OS |
Linux |
Cross-platform build logic |
Build.SourceVersion |
a1b2c3d4 |
Git SHA for tagging |
System.DefaultWorkingDirectory |
/home/vsts/work/1/s |
File path references |
steps:
- script: |
echo "Branch: $(Build.SourceBranch)"
echo "Reason: $(Build.Reason)"
echo "PR Target: $(System.PullRequest.TargetBranch)"
echo "Agent OS: $(Agent.OS)"
displayName: 'Print environment info'
A common pattern — only deploy on pushes to main, never on PR builds:
stages:
- stage: Deploy
condition: |
and(
succeeded(),
eq(variables['Build.SourceBranch'], 'refs/heads/main'),
ne(variables['Build.Reason'], 'PullRequest')
)
Variable Templates and External Variable Files
You can extract variables into separate YAML files and include them with template. This is how you share environment-specific configuration across pipelines without duplicating values.
Variable Template File
# templates/variables/production.yml
variables:
environment: 'production'
azureSubscription: 'Production-Sub'
resourceGroup: 'rg-prod-eastus'
aksCluster: 'aks-prod-eastus'
replicaCount: '3'
logLevel: 'warn'
Consuming a Variable Template
variables:
- template: templates/variables/production.yml
- name: appName
value: 'my-service'
stages:
- stage: Deploy
jobs:
- job: DeployJob
steps:
- script: |
echo "Deploying $(appName) to $(environment)"
echo "Cluster: $(aksCluster), replicas: $(replicaCount)"
Selecting Variable Templates Conditionally
This is where template expressions shine. You can select which variable file to include based on a parameter:
parameters:
- name: environment
type: string
default: 'staging'
values:
- staging
- production
variables:
- template: templates/variables/${{ parameters.environment }}.yml
- name: appName
value: 'my-service'
When someone runs the pipeline and selects production, the compile-time expression resolves the template path to templates/variables/production.yml. This is a compile-time operation, so the correct file is included before the pipeline plan is finalized.
Conditional Variable Assignment with ${{ if }}
Template expressions support if/elseif/else for conditional variable assignment. Because these are compile-time, they can control which variables exist and what values they hold.
parameters:
- name: environment
type: string
default: 'staging'
variables:
- name: appName
value: 'my-service'
- ${{ if eq(parameters.environment, 'production') }}:
- name: replicaCount
value: '5'
- name: logLevel
value: 'warn'
- name: domainName
value: 'api.mycompany.com'
- ${{ elseif eq(parameters.environment, 'staging') }}:
- name: replicaCount
value: '2'
- name: logLevel
value: 'info'
- name: domainName
value: 'api-staging.mycompany.com'
- ${{ else }}:
- name: replicaCount
value: '1'
- name: logLevel
value: 'debug'
- name: domainName
value: 'localhost'
You can also use ${{ if }} to conditionally include or exclude entire stages:
stages:
- stage: Build
jobs:
- job: BuildJob
steps:
- script: echo "Building"
- ${{ if eq(parameters.environment, 'production') }}:
- stage: ApprovalGate
jobs:
- job: WaitForApproval
pool: server
steps:
- task: ManualValidation@0
inputs:
notifyUsers: '[email protected]'
instructions: 'Approve production deployment'
- stage: Deploy
jobs:
- job: DeployJob
steps:
- script: echo "Deploying"
When the environment is not production, the ApprovalGate stage does not exist in the compiled pipeline at all — it is not skipped, it is structurally removed.
Complete Working Example
Here is a full pipeline that demonstrates every variable mechanism covered in this article. It builds a Node.js application, computes a version tag, passes it across stages, conditionally deploys based on branch and parameter, and handles secrets properly.
# azure-pipelines.yml
trigger:
branches:
include:
- main
- release/*
parameters:
- name: environment
displayName: 'Target Environment'
type: string
default: 'staging'
values:
- development
- staging
- production
- name: skipTests
displayName: 'Skip tests'
type: boolean
default: false
# Compile-time conditional variable selection
variables:
- template: templates/variables/${{ parameters.environment }}.yml
- group: shared-secrets
- name: nodeVersion
value: '20.x'
- name: isMainBranch
value: ${{ eq(variables['Build.SourceBranch'], 'refs/heads/main') }}
- ${{ if eq(parameters.environment, 'production') }}:
- name: healthCheckRetries
value: '10'
- ${{ else }}:
- name: healthCheckRetries
value: '3'
stages:
# ============================================================
# Stage 1: Build and Test
# ============================================================
- stage: Build
displayName: 'Build & Test'
jobs:
- job: BuildAndTest
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: $(nodeVersion)
displayName: 'Install Node.js $(nodeVersion)'
- script: npm ci
displayName: 'Install dependencies'
- script: npm run build
displayName: 'Build application'
# Conditionally skip tests via parameter (compile-time)
- ${{ if ne(parameters.skipTests, true) }}:
- script: npm test
displayName: 'Run unit tests'
- script: npm run test:integration
displayName: 'Run integration tests'
env:
TEST_DB_PASSWORD: $(dbPassword) # Secret from variable group
# Set output variable for downstream stages
- script: |
PACKAGE_VERSION=$(node -p "require('./package.json').version")
GIT_SHORT_SHA=$(echo $(Build.SourceVersion) | cut -c1-7)
IMAGE_TAG="${PACKAGE_VERSION}-${GIT_SHORT_SHA}"
echo "Computed image tag: ${IMAGE_TAG}"
echo "##vso[task.setvariable variable=imageTag;isoutput=true]${IMAGE_TAG}"
echo "##vso[task.setvariable variable=packageVersion;isoutput=true]${PACKAGE_VERSION}"
name: setVersion
displayName: 'Compute version tag'
# Print all variable types for debugging
- script: |
echo "=== Compile-time variables ==="
echo "Environment (param): ${{ parameters.environment }}"
echo "isMainBranch: $(isMainBranch)"
echo "healthCheckRetries: $(healthCheckRetries)"
echo ""
echo "=== Runtime / predefined variables ==="
echo "Branch: $(Build.SourceBranch)"
echo "Build reason: $(Build.Reason)"
echo "Build ID: $(Build.BuildId)"
echo "Agent OS: $(Agent.OS)"
echo ""
echo "=== Dynamic variables ==="
echo "Image tag: $(setVersion.imageTag)"
displayName: 'Debug: Print all variable types'
# ============================================================
# Stage 2: Publish Artifact
# ============================================================
- stage: Package
displayName: 'Package Artifact'
dependsOn: Build
variables:
# Cross-stage output variable reference
imageTag: $[ stageDependencies.Build.BuildAndTest.outputs['setVersion.imageTag'] ]
packageVersion: $[ stageDependencies.Build.BuildAndTest.outputs['setVersion.packageVersion'] ]
jobs:
- job: DockerBuild
pool:
vmImage: 'ubuntu-latest'
steps:
- script: |
echo "Building Docker image with tag: $(imageTag)"
docker build -t myregistry.azurecr.io/my-service:$(imageTag) .
displayName: 'Build Docker image'
- script: |
echo "Pushing to registry"
echo $(registryPassword) | docker login myregistry.azurecr.io -u $(registryUsername) --password-stdin
docker push myregistry.azurecr.io/my-service:$(imageTag)
displayName: 'Push Docker image'
env:
registryPassword: $(acrPassword) # Secret via env mapping
# Pass imageTag to the deploy stage
- script: |
echo "##vso[task.setvariable variable=finalTag;isoutput=true]$(imageTag)"
name: publishTag
displayName: 'Publish image tag'
# ============================================================
# Stage 3: Deploy (conditional)
# ============================================================
- stage: Deploy
displayName: 'Deploy to ${{ parameters.environment }}'
dependsOn: Package
# Runtime condition: only deploy from main or release branches
condition: |
and(
succeeded(),
or(
eq(variables['Build.SourceBranch'], 'refs/heads/main'),
startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')
)
)
variables:
deployTag: $[ stageDependencies.Package.DockerBuild.outputs['publishTag.finalTag'] ]
jobs:
- job: DeployApp
pool:
vmImage: 'ubuntu-latest'
steps:
- script: |
echo "Deploying image tag $(deployTag) to ${{ parameters.environment }}"
echo "Replica count: $(replicaCount)"
echo "Log level: $(logLevel)"
echo "Domain: $(domainName)"
echo "Health check retries: $(healthCheckRetries)"
displayName: 'Deploy application'
- script: |
echo "Running health check with $(healthCheckRetries) retries"
RETRIES=$(healthCheckRetries)
for i in $(seq 1 $RETRIES); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://$(domainName)/health)
if [ "$STATUS" = "200" ]; then
echo "Health check passed on attempt $i"
exit 0
fi
echo "Attempt $i failed (status: $STATUS), retrying..."
sleep 10
done
echo "Health check failed after $RETRIES attempts"
exit 1
displayName: 'Health check'
# ============================================================
# Stage 4: Notify (always runs)
# ============================================================
- stage: Notify
displayName: 'Send Notifications'
dependsOn: Deploy
condition: always()
variables:
deployResult: $[ dependencies.Deploy.result ]
jobs:
- job: SendNotification
pool:
vmImage: 'ubuntu-latest'
steps:
- script: |
if [ "$(deployResult)" = "Succeeded" ]; then
echo "Deployment to ${{ parameters.environment }} succeeded"
elif [ "$(deployResult)" = "Skipped" ]; then
echo "Deployment was skipped (branch condition not met)"
else
echo "Deployment to ${{ parameters.environment }} FAILED"
fi
displayName: 'Report deployment result'
Supporting Variable Template
# templates/variables/staging.yml
variables:
environment: 'staging'
azureSubscription: 'Staging-Sub'
resourceGroup: 'rg-staging-eastus'
aksCluster: 'aks-staging-eastus'
replicaCount: '2'
logLevel: 'info'
domainName: 'api-staging.mycompany.com'
# templates/variables/production.yml
variables:
environment: 'production'
azureSubscription: 'Production-Sub'
resourceGroup: 'rg-prod-eastus'
aksCluster: 'aks-prod-eastus'
replicaCount: '5'
logLevel: 'warn'
domainName: 'api.mycompany.com'
Common Issues & Troubleshooting
1. Variable Group Values Are Empty in Template Expressions
Symptom: You use ${{ variables.myVar }} to reference a variable from a variable group, and it always evaluates to empty string.
Error/Behavior:
Stage "Deploy" is skipped because condition evaluated to False:
eq('', 'production')
Cause: Template expressions (${{ }}) are resolved at compile time. Variable group values are fetched at runtime. The value does not exist yet when the expression is evaluated.
Fix: Use a runtime condition instead:
# Before (broken)
- ${{ if eq(variables.deployTarget, 'production') }}:
# After (works)
- stage: Deploy
condition: eq(variables.deployTarget, 'production')
2. Output Variable Is Empty in Downstream Job/Stage
Symptom: You set an output variable with ##vso[task.setvariable] but the downstream job or stage sees an empty value.
Error/Behavior:
Deploying image tag to production
^ empty
Cause: One of three things:
- You forgot
isoutput=truein the logging command - The step does not have a
nameproperty - You used the wrong dependency path (
dependenciesvsstageDependencies)
Fix:
# Step MUST have a name
- script: echo "##vso[task.setvariable variable=myVar;isoutput=true]myValue"
name: myStep # This is required
# Same-stage job reference
variables:
val: $[ dependencies.JobName.outputs['myStep.myVar'] ]
# Cross-stage reference
variables:
val: $[ stageDependencies.StageName.JobName.outputs['myStep.myVar'] ]
3. Macro Syntax Left as Literal String
Symptom: Your script outputs the literal text $(myVariable) instead of the variable value.
Error/Behavior:
$ echo "Config: $(nonExistentVar)"
Config: $(nonExistentVar)
Cause: The variable is not defined anywhere — not in YAML, not in a variable group, not in the UI, and not set dynamically by a prior step. Macro syntax does not error on missing variables; it passes through literally.
Fix: Verify the variable name is spelled correctly and is defined at a scope visible to the current step. Use the pipeline run's "Variables" tab to inspect what is available. Add a debug step:
- script: |
echo "All environment variables:"
env | sort | grep -i "myVariable" || echo "Variable not found"
displayName: 'Debug: Find variable'
4. Secret Variable Not Available in Script
Symptom: A script step references a secret variable, but the value is empty (not masked, just empty).
Error/Behavior:
$ echo "Token length: ${#API_TOKEN}"
Token length: 0
Cause: Secret variables are not automatically mapped to environment variables in script tasks. They must be explicitly mapped via the env property.
Fix:
- script: |
echo "Token length: ${#API_TOKEN}"
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com
env:
API_TOKEN: $(apiToken)
displayName: 'Call API with secret'
5. Conditional ${{ if }} Block Produces YAML Parsing Error
Symptom: Your pipeline fails with a YAML parsing error when using ${{ if }} inside a variable list.
Error:
/azure-pipelines.yml: (Line: 15, Col: 5): A mapping was not expected
Cause: The ${{ if }} directive must produce valid YAML structure. A common mistake is mixing indentation levels or forgetting that conditional blocks inside variables: must produce list items, not mappings.
Fix: Ensure the conditional block produces items matching the surrounding structure:
variables:
- name: alwaysPresent
value: 'yes'
# The ${{ if }} block must produce list items (- name/value pairs)
- ${{ if eq(parameters.env, 'prod') }}:
- name: replicas
value: '5'
- ${{ else }}:
- name: replicas
value: '1'
6. Runtime Expression $[] Returns Empty in Display Name
Symptom: You use $[variables.myVar] in a display name and it is not resolved.
Error/Behavior:
Step: $[variables.buildConfig]
Cause: Runtime expressions ($[ ]) are only evaluated in specific contexts: condition, variables definitions, and certain task properties. Display names do not support runtime expressions. Use macro syntax $(myVar) for display names.
Fix:
# Before (broken)
- script: echo "hello"
displayName: 'Build $[variables.buildConfig]'
# After (works)
- script: echo "hello"
displayName: 'Build $(buildConfig)'
Best Practices
Use parameters instead of queue-time variables. Parameters give you type safety, dropdown menus, and compile-time validation. Queue-time variables are untyped strings that can be set to anything. If a human picks the value, make it a parameter.
Pass secrets through
envmappings, never inline. Referencing$(secret)directly in ascriptvalue puts the secret on the process command line, where it may appear in process listings. Usingenv: MY_SECRET: $(secret)passes it as an environment variable, which is both safer and more portable.Prefer
${{ }}for structural decisions,$[]for runtime conditions. If you need to include or exclude a stage, use template expressions. If you need to decide whether a step runs based on a value that could be set dynamically, use aconditionwith runtime expressions. Mixing these up is the number-one source of pipeline bugs.Always name steps that set output variables. The
nameproperty on a step is how downstream jobs and stages reference its output. Without it, your##vso[task.setvariable variable=x;isoutput=true]calls are silently useless. Make step names descriptive:name: computeVersion, notname: step1.Use variable templates for environment-specific config. Instead of littering your pipeline with
${{ if }}blocks for every environment variable, create per-environment variable files (staging.yml,production.yml) and select them withtemplate: variables/${{ parameters.environment }}.yml. This keeps your main pipeline clean and your config centralized.Log your variable values early in the pipeline for debugging. Add a step at the start of each stage that prints non-secret variable values. When a pipeline fails, this is the first place you look. Secret values will automatically print as
***, so you can safely echo everything.Avoid deeply nested template expression logic. If your
${{ if }}blocks are three levels deep, you have outgrown inline conditionals. Extract the logic into a template file with parameters, or use variable templates with per-environment files. Deeply nested${{ }}blocks are nearly impossible to debug when they produce unexpected YAML.Use
dependsOnexplicitly when referencing output variables. Azure DevOps infers stage dependencies fromdependsOn, and output variable references only work when the dependency is declared. If you removedependsOnfrom a stage that referencesstageDependencies, the reference silently returns empty.Document non-obvious variable sources. When a variable comes from a variable group, a Key Vault, or a dynamically computed output, add a YAML comment explaining where the value originates. Six months from now, someone (probably you) will stare at
$(deployToken)and have no idea where it is defined.
