skip to content

Azure Pipelines YAML has three expression syntaxes — ${{ }}, $[ ] and $( ). When is each evaluated, and what breaks if you pick the wrong one?

level: middleimportance: must knowfreq 68%

answer

  1. three tokens, three different clocks
  2. one of them runs before any agent exists
  3. only one can delete YAML blocks
  4. late substitution wins for script-set values
  5. an undefined macro passes through literally

basics

~20 s

Three different times. ${{ }} is a template expression evaluated at compile time before the run is scheduled, $[ ] is a runtime expression evaluated when the stage or job starts, and $( ) is macro syntax substituted just before each task executes.

solid answer

~50 s

They differ by *when* the value is resolved. `${{ }}` is a **template expression**: Azure evaluates it during template expansion, before any agent is allocated, so it can read `parameters` and statically-known variables — and it is the only syntax that can add or remove YAML structure, via `${{ if }}` and `${{ each }}`. `$[ ]` is a **runtime expression**: evaluated when the run reaches that stage or job, so it can read things that did not exist at compile time, such as a previous job's output variable or `counter()`. It must occupy the whole right-hand side of a value. `$( )` is **macro syntax**: a plain textual substitution performed by the agent immediately before a task runs, mostly used in task inputs and scripts. The classic failure is reading a variable a script set with `##vso[task.setvariable]` using `${{ variables.x }}` — that value was baked in at compile time, long before the script ran, so you get the old value or an empty string.

code

yaml · 21 lines
yaml
parameters:
  - name: deployTarget
    type: string
    default: staging
    values:
      - staging
      - production

variables:
  releaseCounter: $[ counter(format('{0}', parameters.deployTarget), 1) ]

steps:
  - script: echo "##vso[task.setvariable variable=sha]$(git rev-parse --short HEAD)"
    displayName: Capture sha

  - script: echo "target=${{ parameters.deployTarget }} build=$(releaseCounter) sha=$(sha)"
    displayName: Print all three forms

  - ${{ if eq(parameters.deployTarget, 'production') }}:
      - script: ./scripts/smoke-test.sh
        displayName: Production smoke test

go deeper

for a junior

Recognise all three tokens on sight and know that $( ) is the everyday one you use inside scripts and task inputs. Say plainly that ${{ }} is resolved before the run starts.

for a middle

Explain the three evaluation times and the consequence of each: ${{ }} can add or delete YAML, $[ ] must be the whole value, $( ) resolves late and passes through unresolved. Be able to diagnose the script-set-variable trap.

for a senior

Show judgment about where a decision belongs — parameters and ${{ if }} for shape fixed at queue time, condition: for anything reacting to the run. Explain why secrets are unreachable from compile-time expressions.

for a principal

Own the readability cost: heavy ${{ each }} generation produces pipelines nobody can read in the editor, and the expanded YAML is what actually runs. Decide how much compile-time metaprogramming a platform template may use before it becomes unmaintainable.

## Three clocks, not three styles The reason Azure Pipelines has three token forms is that a pipeline is processed in three distinct phases, and each syntax belongs to one of them. | Syntax | Name | Evaluated | Can it change the YAML shape? | |---|---|---|---| | `${{ }}` | template expression | at compile time, before scheduling | yes | | `$[ ]` | runtime expression | at stage/job start | no | | `$( )` | macro syntax | just before the task runs | no | ## `${{ }}` — compile time When a run is queued, Azure first *expands* the YAML: it pulls in templates, substitutes parameters and evaluates every `${{ }}`. The result is a fully-materialised pipeline document; only then is anything scheduled onto an agent. Because this happens before execution, `${{ }}` is the only syntax that can decide whether a block of YAML exists at all: ```yaml parameters: - name: runIntegration type: boolean default: false steps: - script: npm test - ${{ if eq(parameters.runIntegration, true) }}: - script: npm run test:integration ``` With `${{ if }}`, the skipped step is not *skipped* — it is **absent** from the expanded pipeline, so it never appears in the run at all. The same applies to `${{ each }}`, which generates repeated blocks from an `object` parameter, and `${{ insert }}`, which merges a mapping in. What `${{ }}` cannot see is anything that does not exist yet: a variable a script set during the run, a job's output, or a secret from a Key Vault-linked group. Reading those with `${{ }}` does not error loudly — it resolves to the compile-time value, or to an empty string, and the pipeline carries on with the wrong data. That silence is what makes this the platform's most common trap. ## `$[ ]` — runtime A runtime expression is evaluated when the run actually reaches the stage or job that owns it, so it can read state produced earlier in the same run. It has a strict placement rule: **it must be the entire right-hand side of the value**, not embedded in a larger string. ```yaml variables: buildNumber: $[ counter('release', 1) ] gitSha: $[ dependencies.Build.outputs['setVars.sha'] ] ``` `$[ 'v' ]-suffix` is invalid; wrap concatenation in `format()` instead. One place you never type the brackets is `condition:` — that field is already a runtime-expression context, so you write `condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')` bare. ## `$( )` — macro substitution Macro syntax is not really an expression language; it is the agent doing find-and-replace on task inputs and script bodies immediately before the task executes. It is what you use for ordinary variable interpolation: ```yaml steps: - script: echo "building $(Build.BuildId) on $(Agent.OS)" - task: PublishPipelineArtifact@1 inputs: artifact: app-$(Build.BuildId) ``` Two behaviours matter. First, macros resolve *late*, so `$(myVar)` correctly picks up a value a previous step set with `echo "##vso[task.setvariable variable=myVar]abc"`. Second, an **undefined** macro is not an error: the literal text `$(notSet)` is passed through to the task unchanged, which surfaces as a bizarre filename or a URL with a dollar sign in it rather than a clean failure. ## The failure this actually causes ```yaml steps: - script: echo "##vso[task.setvariable variable=tag]$(git rev-parse --short HEAD)" - script: echo "deploying ${{ variables.tag }}" # empty — compile time - script: echo "deploying $(tag)" # correct — runtime ``` The second step prints nothing useful. `${{ variables.tag }}` was resolved during expansion, minutes before the first step ran, so there was no such variable to read. The same shape of bug appears when someone tries to pass a runtime value into a template parameter: template parameters are consumed at compile time, so a runtime value can never reach one. If a decision genuinely depends on runtime state, it has to be expressed as a `condition:` on a step, job or stage — not as a `${{ if }}`. ## Secrets and precedence Secret variables are deliberately unavailable to `${{ }}`; they exist only at runtime, and only as `$(name)` in task inputs or as values you map explicitly into a script's environment. Any design that needs a secret to *shape* the pipeline is a design that needs rethinking, because the shape is fixed before the secret is fetched. A practical rule of thumb: use `${{ }}` for anything derived from `parameters`, `$[ ]` when you need a value computed earlier in the same run, and `$( )` everywhere else inside steps.

  • Why can't a template parameter take a value produced by a script earlier in the run?
    Because parameters are consumed during template expansion, which finishes before the first agent is allocated. A script-produced value does not exist yet, so there is nothing to pass. Runtime decisions belong in a `condition:` on a step, job or stage — or in a `$[ ]` runtime expression — never in a `${{ }}` parameter.
  • What is the practical difference between a step removed by ${{ if }} and one skipped by condition:?
    `${{ if }}` removes the step from the expanded YAML entirely — it never appears in the run's timeline. A false `condition:` leaves the step in the pipeline and shows it as skipped in the logs. The first is decided at compile time from parameters; the second can react to what happened during the run.
  • Do you need $[ ] brackets inside a condition: field?
    No. `condition:` is already evaluated as a runtime expression, so you write the function call bare — `condition: and(succeeded(), eq(variables.isMain, 'true'))`. Adding brackets there is a common cargo-cult error. Conversely, a `variables:` entry that needs runtime evaluation does require the `$[ ]` wrapper, occupying the whole value.
  • What happens if a task input contains $(missingVar) and no such variable is defined?
    Nothing fails. Macro syntax is textual substitution, and an unresolved macro is passed through verbatim, so the task receives the literal string `$(missingVar)`. That typically shows up downstream as a strange artifact name or path rather than an obvious pipeline error, which makes it slow to diagnose.

saying these in an interview costs you the question

  • Treats all three syntaxes as interchangeable styles
  • Uses ${{ }} to read a variable a script just set
  • Thinks ${{ if }} and condition: mean the same thing
  • Expects an undefined $( ) macro to fail the build
  • Tries to pass a runtime value into a template parameter

context