Mert Özen Further With Every Line
DevOps Azure DevOps Pipelines — #02

Variables, Parameters and Expressions in Azure Pipelines: ${{ }}, $( ) and $[ ] Explained

Mert Özen Nov 15, 2026 13 min 5 views
Variables, Parameters and Expressions in Azure Pipelines: ${{ }}, $( ) and $[ ] Explained

In Azure Pipelines ${{ }} is evaluated while the file is processed, $( ) right before each task and $[ ] when a job or stage starts; the form you pick decides whether the value exists at that moment. We compute Bookstore's version in one job and pass it on as an output, and cover why secrets don't reach the environment on their own, runtime parameters, variable groups and CI, scheduled and pipeline completion triggers.

Version note: The scripts and the .NET 10 (SDK 10.0.404) build in this post were run locally; the pipeline file was parsed and checked with yq 4.54. How variables, expressions and triggers behave inside Azure Pipelines is based on the official documentation on Microsoft Learn (November 2026 versions).

Guess First

Ayşe wants to change a variable's value during a Bookstore pipeline run and read it in the next step. The variable starts as initial; the first step overwrites it with the run number, and the second step prints the same variable in two different forms:

variables:
  apiImageTag: initial

steps:
  - script: echo "##vso[task.setvariable variable=apiImageTag]$(Build.BuildId)"
    displayName: Override tag

  - script: |
      echo "template: ${{ variables.apiImageTag }}"
      echo "macro:    $(apiImageTag)"
    displayName: Print tag

Say the run number is 1287. What do the two lines in the second step's log say?

The answer: template: initial and macro: 1287. ${{ variables.apiImageTag }} is a template expression and is evaluated while the file is processed, before the pipeline ever reaches an agent; at that moment the variable still holds initial from the file. By the time that line reaches the agent it is no longer an expression but plain text. $(apiImageTag) is macro syntax: the agent substitutes the value just before each step runs, and by then the first step has already changed it with a logging command. The "Define variables" page on Microsoft Learn shows the same behaviour with its own example. The rest of this post is about why that difference matters.

Three Forms, Three Moments: ${{ }}, $( ) and $[ ]

Azure Pipelines processes a run in three phases. First it works on the YAML text: it expands templates and evaluates template expressions written with ${{ }}. Then it orders stages and jobs and evaluates conditions. Last, the agent replaces $( ) macros right before each step. $[ ] runtime expressions are evaluated during the run, before the job or stage they belong to starts. Here are the differences in one table:

FormWhen it is processedWhere it is usedIf the variable is missing
${{ variables.x }}, ${{ parameters.x }}While the file is processed, at compile timeKey or value; conditional and iterative insertionEmpty string
$(x)At run time, right before each taskValues only; task inputs and script bodiesThe text stays as is: $(x)
$[ variables.x ]At run time, when a job or stage startsValues only, and the whole value; variable definitions, conditionsEmpty string

In practice the choice goes like this. If you are changing the shape of the YAML based on a parameter or a value fixed in the file (adding a step, multiplying a job, choosing the name of a key), use ${{ }}. If you are passing a value to a task or reading it inside a script, $( ) is enough; it is also the right form for secrets and predefined variables. If you need another job's output or a condition built on a value from the run, you need $[ ]. Two rules are often forgotten: macros are expanded only in task inputs within stages, jobs and steps, not in keys resolved while the file is processed such as trigger or resources; and a runtime expression must take up the whole value, so key: $[ variables.x ]-suffix is not valid.

Variable Scopes and Predefined Variables

You can define a variable at three levels: at the root (the whole pipeline), under a stage (that stage only) and under a job (that job only). If the same name exists at more than one level, the narrowest wins: job level overrides stage level, stage level overrides the root. All variables are strings; even if you write true, it is compared as the text 'true'.

Every non-secret variable also reaches steps as an environment variable. The name is upper-cased and dots become underscores: Build.SourceBranch becomes $BUILD_SOURCEBRANCH in bash. Variables whose names start with endpoint, input, secret, path or securefile never reach tasks and scripts at all; those prefixes are reserved by the system.

Azure Pipelines defines dozens of variables itself on every run. The ones this series uses most: Build.SourceBranch (the full ref, e.g. refs/heads/main), Build.SourceBranchName (only the last segment), Build.BuildId (the run's numeric ID), Build.Reason (what started the run: Manual, IndividualCI, BatchedCI, Schedule, PullRequest, BuildCompletion, ResourceTrigger and so on), Build.SourcesDirectory and Pipeline.Workspace. In a condition, names with dots need index syntax: variables['Build.SourceBranch']; variables.Build.SourceBranch does not work.

Producing Values with Logging Commands and Passing Them Between Jobs

From this post on, Bookstore's API, the Angular build output and the package we will produce later carry the same version number within a run. We compute the version once, in a separate job, and hand it to the other jobs as an output. First we added <VersionPrefix>1.0.0</VersionPrefix> to Directory.Build.props, then wrote this script:

#!/usr/bin/env bash
# Computes the application version once per run and publishes it as an output variable.
# Version policy (SemVer, changelog) is out of scope here; this only adds a branch-based suffix.
set -euo pipefail

prefix=$(sed -n 's:.*<VersionPrefix>\(.*\)</VersionPrefix>.*:\1:p' Directory.Build.props)
branch="${BUILD_SOURCEBRANCH:-refs/heads/local}"
build_id="${BUILD_BUILDID:-0}"

case "$branch" in
  refs/heads/main)      version="${prefix}-main.${build_id}" ;;
  refs/heads/release/*) version="${prefix}-rc.${build_id}" ;;
  *)                    version="${prefix}-ci.${build_id}" ;;
esac

echo "App version: ${version}"
echo "##vso[task.setvariable variable=appVersion;isOutput=true]${version}"

The script reads predefined variables as environment variables, so we can pass the values the agent would send by hand and try it locally:

$ BUILD_SOURCEBRANCH=refs/heads/main BUILD_BUILDID=1287 bash scripts/set-version.sh
App version: 1.0.0-main.1287
##vso[task.setvariable variable=appVersion;isOutput=true]1.0.0-main.1287
$ BUILD_SOURCEBRANCH=refs/heads/release/1.0 BUILD_BUILDID=1287 bash scripts/set-version.sh
App version: 1.0.0-rc.1287
##vso[task.setvariable variable=appVersion;isOutput=true]1.0.0-rc.1287
$ BUILD_SOURCEBRANCH=refs/heads/feature/cart-badge BUILD_BUILDID=1287 bash scripts/set-version.sh
App version: 1.0.0-ci.1287
##vso[task.setvariable variable=appVersion;isOutput=true]1.0.0-ci.1287

Locally the last line is just text. On an agent, the task.setvariable command defines a variable called appVersion, and isOutput=true makes it an output that other jobs can read. SemVer itself and what each suffix should mean belong to the Versioning and Release Management series; here we only look at the mechanics of moving the value. The jobs section of the pipeline became:

jobs:
  - job: Version
    displayName: Compute version
    steps:
      - checkout: self
        fetchDepth: 1
      - script: bash scripts/set-version.sh
        name: setVersion
        displayName: Compute app version

  - job: Api
    displayName: Build and test API
    dependsOn: Version
    variables:
      appVersion: $[ dependencies.Version.outputs['setVersion.appVersion'] ]
    steps:
      - checkout: self

      - task: UseDotNet@2
        displayName: Install .NET SDK from global.json
        inputs:
          packageType: sdk
          useGlobalJson: true

      - script: dotnet restore Bookstore.slnx
        displayName: Restore

      - script: dotnet build Bookstore.slnx --no-restore -c $(buildConfiguration) -p:Version=$(appVersion)
        displayName: Build $(appVersion)

Three details matter. The step that produces the output needs a name:, because the output's full name is setVersion.appVersion. The reading job must be linked with dependsOn: Version; without the dependency, dependencies.Version comes back empty. The value must be assigned with $[ ] to a job-level variable; after that, steps read it as plain $(appVersion). To read the output inside the same job, $(setVersion.appVersion) is enough. To read it from a job in a different stage the form is $[ stageDependencies.Build.Version.outputs['setVersion.appVersion'] ]; according to the documentation that value is available only in the next stage downstream, and a stage further away has to be linked to it explicitly with dependsOn.

The build step passes the version to MSBuild with -p:Version=. Running the same command locally and looking at the version text inside the compiled DLL shows the value:

$ dotnet build Bookstore.slnx --no-restore -c Release -p:Version=1.0.0-main.1287

Build succeeded.
    0 Warning(s)
    0 Error(s)
$ strings src/Bookstore.Api/bin/Release/net10.0/Bookstore.Api.dll | grep -E "^1\.0\.0"
1.0.0.0
1.0.0-main.1287

The first line is the assembly version (numeric part only), the second the informational version; a health or version endpoint on the API can show it later.

Secrets: Why They Don't Arrive on Their Own

The first post's start-test-sql.sh published the connection string as a secret variable with issecret=true, and the test step mapped it explicitly under env:. The documentation is clear about why: unlike normal variables, secret variables are not passed to steps as environment variables automatically. A mapping such as DB: $(mySecret) under the root variables: does not help either; the only right way to put a secret into the environment is the env: block of the step that uses it. That way the file shows exactly which step sees which secret.

The way to put a secret into a pipeline in the first place is not YAML. You use a variable marked "Keep this value secret" in the Variables section of the pipeline editor, or a variable group in the Library. Secrets are encrypted at rest with a 2048-bit RSA key and go to the agent only during a run. Masking has two limits. Azure Pipelines does not mask parts of a secret: the whole value becomes ***, but if only a piece of it reaches the log, it shows as is. That is why secrets should not hold structured data such as JSON. Second, operating systems can log the arguments of commands they run, which is why the recommendation is to pass a secret as an environment variable rather than a command-line argument.

The first post's script follows that rule: docker run -e MSSQL_SA_PASSWORD copies the value from the current environment without writing it out. Running the script locally against a small fake docker that writes its arguments to a file shows the commands that were actually sent:

docker run -d --name bookstore-test-sql -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD -p 1433:1433 mcr.microsoft.com/mssql/server:2025-latest
docker exec -e SQLCMDPASSWORD bookstore-test-sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -Q SELECT 1
docker exec -e SQLCMDPASSWORD bookstore-test-sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -Q SELECT 1
docker exec -e SQLCMDPASSWORD bookstore-test-sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -Q SELECT 1

The password is in none of the arguments. The script also adds the password on its own to the masking list with task.setsecret, because the value marked as secret is the whole connection string, the password is only part of it, and parts are not masked.

Runtime Parameters: Letting People Choose When They Run Manually

Variables can change during a run; parameters cannot. A parameter defined at the top of the pipeline is chosen once when the run starts, substituted while templates are expanded, and fixed from then on. In return it gives you type safety: there are string, number, boolean, object, and types that take a piece of YAML such as step, stepList, job, jobList, stage and stageList; values: limits the allowed values. We put two parameters at the top of the Bookstore pipeline:

parameters:
  - name: buildConfiguration
    displayName: Build configuration
    type: string
    default: Release
    values:
      - Release
      - Debug
  - name: runIntegrationTests
    displayName: Run integration tests
    type: boolean
    default: true

variables:
  buildConfiguration: ${{ parameters.buildConfiguration }}

When you choose "Run pipeline" in the web UI, these two appear as a drop-down and a checkbox; runs started by a trigger use the defaults. Copying the parameter into a variable let us keep writing $(buildConfiguration) in the rest of the file. The boolean parameter is used to add or remove steps entirely:

      - ${{ if eq(parameters.runIntegrationTests, true) }}:
          - script: bash scripts/start-test-sql.sh
            displayName: Start SQL Server for integration tests

          - script: dotnet test --solution Bookstore.slnx --no-build -c $(buildConfiguration)
            displayName: Test
            env:
              ConnectionStrings__BookstoreDb: $(TestDbConnection)

This is not a condition but conditional insertion: if the box is left unticked, these two steps do not exist in the expanded pipeline at all and do not even show as "skipped" on the run page. Parameters cannot carry secrets; anything secret stays in variables.

The typical use in deploy pipelines is choosing an environment. The snippet below summarizes the form we will use in the sixth post: based on the selected value, it links the right variable group while the file is processed.

parameters:
  - name: environment
    type: string
    default: dev
    values:
      - dev
      - staging
      - production

variables:
  - group: bookstore-${{ parameters.environment }}
  - name: buildConfiguration
    value: Release

${{ }} is required here: a variable group is a resource, and resources are authorized before a stage starts; variables read with $( ) do not exist yet at that point. For the same reason you cannot read environment or service connection names from a variable.

Variable Groups and the Library

Values shared by more than one pipeline go into a variable group in the Library under Pipelines. In YAML you link it with - group: bookstore-ci; the variables block then switches to the list form shown above and normal variables are written as name/value pairs. If two groups have a variable with the same name, the group that comes later in the file wins. Secrets in a group count as a protected resource: the first time a pipeline uses the group, the run stops and asks for permission until the pipeline is authorized on the group's Pipeline permissions tab. The "Open access" option allows every pipeline in the project; reasonable for groups without secrets, not for groups with them. A group can also be linked directly to secrets in Azure Key Vault; we will use that in the seventh post when we read a connection string from Key Vault. Key Vault itself was covered in the Configuration and Secret Management series.

Kinds of Triggers

The CI trigger in our file now looks like this:

trigger:
  batch: true
  branches:
    include:
      - main
      - release/*
  paths:
    exclude:
      - web
      - README.md

branches says which branches start a run on push. paths.exclude skips the run when every change is under those paths: this pipeline only builds the API, so a commit that touches only the Angular folder or the README does not take up the queue for nothing. Paths are relative to the repository root, case-sensitive, and cannot contain variables, because variables do not exist yet when the trigger is evaluated. batch: true collects pushes that arrive while a run is in progress; when that run finishes, a single run starts for all of them. The documentation advises care with this in multi-stage pipelines and pipelines that wait for approvals, because the new run does not start until the previous one is done.

We covered the pull request trigger in the first post: a branch policy in Azure Repos, the pr: block on GitHub. Scheduled triggers use schedules:

schedules:
  - cron: "0 2 * * 1-5"
    displayName: Nightly integration run
    branches:
      include:
        - main
    always: false

Cron expressions are always in UTC; "0 2" is 05:00 in Türkiye. always: false is the default: if nothing has changed in the code since the last successful scheduled run, no run starts. If you want the nightly tests to run even without changes, set it to true. Schedules must be in the main pipeline file, not in a template. A schedule added in the web UI overrides the ones in YAML; if you change the cron in an older pipeline and see no effect, look there first.

The fourth kind is the pipeline completion trigger: one pipeline starts when another finishes. For example, to rerun the API pipeline after every successful build of the contract package that the fifth post publishes from a separate pipeline:

resources:
  pipelines:
    - pipeline: contracts
      source: bookstore-contracts
      trigger:
        branches:
          include:
            - main

source is the triggering pipeline's name in Azure DevOps; pipeline is the alias you give it in this file. The documentation lists Build.Reason for such a run as ResourceTrigger or BuildCompletion; account for both when you write conditions.

In Production

Computing the version in a separate job has a cost: that job also starts a VM and uses a parallel job slot. In an organization with a single parallel job, that adds the VM start-up time to the run. In return, the API, Angular and package jobs all take the value from one place; in the third post we move this job into a template. To keep the version job small, the checkout fetches only the last commit with fetchDepth: 1.

Keep track of where variables come from. The same name can be defined at the root, in a variable group, in UI variables in the pipeline settings and by a logging command; finding out which one wins takes time. Making a variable overridable at queue time ("Let users override this value when running this pipeline") is done in the pipeline settings; for anyone who wants to change a value from outside, a runtime parameter is safer because it is typed and limited by values:. Restricting which variables can be set from outside is the topic of the eighth post.

Watch for scheduled runs that quietly stop. When a pipeline is disabled, schedules in YAML are not refreshed, and because of always: false the nightly run does not start on days without changes. Checking once a week when the nightly tests last ran closes a gap nobody notices.

Common Pitfalls

A missing macro staying as it is. If the variable does not exist, $(appVersion) stays as text and bash reads that text as command substitution. Reproducing the case where you forgot to map the output with the same command locally gives this (path shortened):

$ bash -c 'dotnet build Bookstore.slnx --no-restore -c Release -p:Version=$(appVersion)'
bash: appVersion: command not found
…targets(226,5): error MSB4044: The "GetAssemblyVersion" task was not given a value for the required parameter "NuGetVersion".

Build FAILED.

The error talks about an MSBuild task rather than the version; the real clue is command not found on the first line. If you see that line in a pipeline log, first check that the variable is really defined.

Overriding the default when writing a condition. A step or job without a condition runs only if everything before it succeeded; that is the succeeded() default. When you write your own condition, that default is replaced entirely: a deploy step with condition: eq(variables['Build.SourceBranch'], 'refs/heads/main') runs on main even if the tests before it failed. The right form is and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')).

Writing the short branch name. In Azure Repos Build.SourceBranch is always in the form refs/heads/main; eq(variables['Build.SourceBranch'], 'main') is never true. If you want the short name there is Build.SourceBranchName, but remember it only gives the last segment for branches with slashes such as release/1.0 (1.0). Comparisons are case-insensitive, so there is no difference between 'True' and 'true'.

Expecting a runtime variable in a template expression. ${{ variables.appVersion }} always comes back empty, because the value is produced after the file is processed, and a template expression turns a variable it cannot find into an empty string without an error. Since there is no error, the problem only shows up as an empty field in the output.

Looking for a secret in the environment. Reading a secret variable in a script without mapping it through env: gives an empty value. Assigning it to another variable at the root is not the fix either; the mapping belongs on the step itself.

A Small Experiment

★☆☆ Run set-version.sh locally with different BUILD_SOURCEBRANCH values. See which suffix it produces for refs/tags/v1.0.0 and add a separate rule for tags.

★★☆ Add a step to the pipeline that runs when Build.Reason is Schedule, and write its condition so that it does not override succeeded(). Then try writing the same condition with ${{ if }} and explain why it will not work.

★★★ Split the pipeline into two stages, Build and Report. Have a job in Report read the version through stageDependencies. Then add a third stage that tries to read the same value and find which dependsOn it needs.

Our pipeline has grown, and once the Angular job joins we will start writing the same UseDotNet, restore and test steps in other repositories too. In the next post we move that repetition into one shared place with step, job and stage templates, and use an extends template to build a skeleton every pipeline has to follow.