Pipelines

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.Reason in 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:

  1. You forgot isoutput=true in the logging command
  2. The step does not have a name property
  3. You used the wrong dependency path (dependencies vs stageDependencies)

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 env mappings, never inline. Referencing $(secret) directly in a script value puts the secret on the process command line, where it may appear in process listings. Using env: 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 a condition with runtime expressions. Mixing these up is the number-one source of pipeline bugs.

  • Always name steps that set output variables. The name property 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, not name: 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 with template: 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 dependsOn explicitly when referencing output variables. Azure DevOps infers stage dependencies from dependsOn, and output variable references only work when the dependency is declared. If you remove dependsOn from a stage that references stageDependencies, 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.


References

Powered by Contentful