templateContext Deep Dive: Bundling Job Metadata

Apr 29, 2026 min read

You have a deploymentList parameter. Each deployment needs a different Azure environment name, approval group, and service connection. The schema won’t let you add custom properties to a deployment definition. The parallel-array workaround you wrote last month is already out of sync with the deployment list it describes.

jobList, deploymentList, and stageList parameter types accept valid YAML definitions — which means Azure DevOps enforces the schema and rejects any custom property you attach directly. Before templateContext, the only solution was a parallel parameter array: one array of job definitions, one array of metadata objects, indexed in lockstep. That pattern breaks silently when an item is added to one array but not the other. There is no error. The wrong metadata silently maps to the wrong job.

templateContext solves this. This article covers:

  • The exact syntax for attaching metadata to jobs and stages.
  • How to read templateContext properties inside a template that processes a list.
  • The scope constraints that prevent templateContext from being read inside steps: — and the workaround.
  • Four production patterns: environment routing, per-job conditional steps, variable bridging, and multi-tenant regional deployment.
  • A decision framework for when templateContext is the right tool vs. a plain object parameter.

Compatibility: templateContext is fully supported on Azure DevOps Services. On Azure DevOps Server, support begins in version 2022. Earlier Server versions will surface an “Unexpected value ’templateContext’” parse error.


templateContext Mechanics

What templateContext Is and Where It Lives

templateContext is a reserved property on job, deployment, and stage definitions. Azure DevOps’s YAML parser recognizes it explicitly — it does not trigger a schema validation error even though it is not part of the standard job or stage schema. Any YAML mapping you place inside templateContext: is accepted without type checking.

Place templateContext: as a sibling of steps:, pool:, and environment: inside a job definition:

# pipeline.yml — deployment with templateContext metadata
stages:
  - template: templates/deploy-jobs.yml
    parameters:
      deployments:
        - deployment: DeployDev
          pool:
            vmImage: ubuntu-latest
          templateContext:           # sibling of strategy:, pool:, environment:
            targetEnvironment: dev
            serviceConnection: sc-dev
            approvalGroup: dev-approvers
            runSmokeTests: false
          strategy:
            runOnce:
              deploy:
                steps:
                  - script: echo "Building artifacts"
                    displayName: 'Build'

After compilation, templateContext is stripped from the Expanded YAML. The job that the agent receives contains no trace of it:

# Expanded YAML — templateContext is absent; the agent never sees it
jobs:
  - deployment: DeployDev
    pool:
      vmImage: ubuntu-latest
    strategy:
      runOnce:
        deploy:
          steps:
            - script: echo "Building artifacts"
              displayName: 'Build'

templateContext has no runtime existence. It is purely a compile-time data carrier — available during template expansion (Phase 2 of initialization) and gone afterward.

The Access Scope Rule

templateContext properties are accessible exclusively inside a ${{ each }} loop in the template file that receives the jobList or deploymentList. Access uses compile-time expression syntax:

# CORRECT: read templateContext inside the ${{ each }} loop in the template
- ${{ each deploy in parameters.deployments }}:
  - deployment: ${{ deploy.deployment }}
    environment:
      name: ${{ deploy.templateContext.targetEnvironment }}   # compile-time access

templateContext does not exist inside the job’s steps: at runtime. The agent executes steps after compilation — by that point templateContext has been stripped. Any attempt to read ${{ deploy.templateContext.myProp }} inside a steps: block at runtime returns an empty string or causes an evaluation error.

Three patterns bridge the compile-time/runtime gap:

Pattern 1 — Job-level variables injection (most common):

# templates/deploy-jobs.yml
- ${{ each deploy in parameters.deployments }}:
  - deployment: ${{ deploy.deployment }}
    variables:
      # Extract templateContext values into pipeline variables at compile time
      - name: TARGET_ENV
        value: ${{ deploy.templateContext.targetEnvironment }}
      - name: SERVICE_CONN
        value: ${{ deploy.templateContext.serviceConnection }}
    pool:
      vmImage: ubuntu-latest
    strategy:
      runOnce:
        deploy:
          steps:
            - ${{ deploy.strategy.runOnce.deploy.steps }}
            - script: echo "Deployed to $(TARGET_ENV) via $(SERVICE_CONN)"
              displayName: 'Report'

Pattern 2 — env: block on a specific step:

- ${{ each deploy in parameters.deployments }}:
  - deployment: ${{ deploy.deployment }}
    strategy:
      runOnce:
        deploy:
          steps:
            - script: |
                echo "Region: $REGION"
                ./deploy.sh --region "$REGION"
              displayName: 'Deploy'
              env:
                REGION: ${{ deploy.templateContext.region }}  # injected as env var

Pattern 3 — Parameter passed to an inner template:

- ${{ each deploy in parameters.deployments }}:
  - deployment: ${{ deploy.deployment }}
    strategy:
      runOnce:
        deploy:
          steps:
            - template: steps/deploy-steps.yml
              parameters:
                # Pass the templateContext value as a typed parameter to the inner template
                targetEnvironment: ${{ deploy.templateContext.targetEnvironment }}
                serviceConnection: ${{ deploy.templateContext.serviceConnection }}

Use Pattern 1 (job-level variables) when multiple steps in the job need the value. Use Pattern 2 (env: block) when only one step needs it and you want to keep the scope narrow. Use Pattern 3 (inner template parameter) when the inner template is shared and typed validation is important.

Diagram: Metadata Propagation with templateContext

This diagram visualizes the flow of metadata from its definition in the pipeline root to its consumption in a template, and finally its propagation to the runtime environment via the Variable Bridge pattern.

D-IRVvF-(ASeeteaaitgccfdtearrndveerhieerdiiaeamnionpmaaalprptpelptMbblilt'oleellJoaaPL$dyateeoybthoC(emtPLasbmleaaoTpeehidB:eeCsdnGlnCasarDnsoessTotostti-et:nu)y:ne:adf:t4V.'mt:gni-e:a.eDe2'eanDxrnex:$'mientIitpt{$PetpanaLl:T{{a:ilmiibPioe{tooestlisy{metTny:ieptDpadeA:DsaseeelcerREeTtllwvnahpnGxvAriiivtl:EpRiznt:edoTaGpaehey_nEpt'Ep.EdTeiRtdxltNe_dooeepoeVdE)nomvaymNtp'npvYV&lsilaAYa}inalMvEAtotuLaxMenpeeleLCaC:uco(roeunCan':ttomt$iemee{'oxptx{dntiet.eDf:lr..v(iaese.'Rri-.neuelTdvnncsievtt:mp}}iel}}ma)o''ecy)cmeesnsts}}'|

Visual Notes:

  • The Bridge: Because templateContext is stripped during compilation, the template must “bridge” the value into a standard variable or environment variable for the agent to see it.
  • Scope Restriction: The blue zone is the only place where direct ${{ deploy.templateContext }} access is valid. In the orange zone (Runtime), only the bridged variables exist.

Schema Validation Behavior

Azure DevOps recognizes templateContext as a reserved keyword. Adding it to a job definition inside a deploymentList parameter does not trigger the standard “Unexpected value” schema error:

# INVALID: custom property added directly to a job definition — schema error
- deployment: DeployDev
  targetEnvironment: dev   # "Unexpected value 'targetEnvironment'"
  strategy:
    runOnce:
      deploy:
        steps:
          - script: echo "deploy"

# VALID: same property moved inside templateContext — no schema error
- deployment: DeployDev
  templateContext:
    targetEnvironment: dev   # accepted without schema validation
  strategy:
    runOnce:
      deploy:
        steps:
          - script: echo "deploy"

The content inside templateContext: is entirely unvalidated. Azure DevOps accepts any YAML mapping — misspelled property names, wrong value types, and absent required properties all pass without parse errors. The errors surface when properties are consumed: ${{ deploy.templateContext.typo }} evaluates to an empty string silently; ${{ coalesce(deploy.templateContext.typo, 'fallback') }} applies the fallback.

templateContext is valid on jobs and stages passed via jobList and stageList parameters. It is also valid on deployment jobs passed via deploymentList. It is not valid on steps passed via stepList. It is also not valid on jobs or stages defined directly in the pipeline root YAML — those are not processed by a ${{ each }} loop, and the parser surfaces an “Unexpected value ’templateContext’” error if used there.


templateContext with deploymentList

The Basic Pattern

The pipeline file defines a deploymentList parameter and passes a list of deployment definitions, each with a templateContext: block. The template file receives the list and iterates with ${{ each deploy in parameters.deployments }}:

# pipeline.yml — two deployments, each with targetEnvironment in templateContext
stages:
  - template: templates/deploy-jobs.yml
    parameters:
      deployments:
        - deployment: DeployDev
          templateContext:
            targetEnvironment: dev
          strategy:
            runOnce:
              deploy:
                steps:
                  - script: echo "Deploy dev"
                    displayName: 'Deploy'

        - deployment: DeployProd
          templateContext:
            targetEnvironment: prod
          strategy:
            runOnce:
              deploy:
                steps:
                  - script: echo "Deploy prod"
                    displayName: 'Deploy'
# templates/deploy-jobs.yml — iterates and routes to the correct Azure environment
parameters:
  - name: deployments
    type: deploymentList
    default: []

jobs:
  - ${{ each deploy in parameters.deployments }}:
    - deployment: ${{ deploy.deployment }}
      # Set the Azure environment from per-job templateContext
      environment:
        name: ${{ deploy.templateContext.targetEnvironment }}
        resourceName: myapp-${{ deploy.templateContext.targetEnvironment }}
      strategy: ${{ deploy.strategy }}
      pool: ${{ deploy.pool }}

Subsequent properties (environment:) map specific metadata into the job definition. Explicit property mapping (deployment: ${{ deploy.deployment }}, strategy: ${{ deploy.strategy }}) avoids “duplicate key” schema errors that occur when using the raw - ${{ deploy }} merge syntax.

Production Pattern 1 — Environment Routing

Each job in the list targets a different Azure DevOps environment — with its own approval gates, service connection, and notification channel. All three pieces of per-job metadata travel with the job definition:

# pipeline.yml — three deployment jobs, each with full routing metadata
stages:
  - stage: Deploy
    jobs:
      - template: templates/deploy-job.yml
        parameters:
          containerRegistry: myregistry.azurecr.io
          imageTag: $(Build.BuildId)
          deployments:
            - deployment: DeployDev
              templateContext:
                environment: dev
                serviceConnection: sc-dev
                approvalGroup: dev-approvers
                resourceGroup: rg-myapp-dev
              strategy:
                runOnce:
                  deploy:
                    steps:
                      - script: echo "Pre-deploy checks"
                        displayName: 'Pre-flight'

            - deployment: DeployStaging
              templateContext:
                environment: staging
                serviceConnection: sc-staging
                approvalGroup: platform-team
                resourceGroup: rg-myapp-staging
              strategy:
                runOnce:
                  deploy:
                    steps:
                      - script: echo "Pre-deploy checks"
                        displayName: 'Pre-flight'

            - deployment: DeployProd
              templateContext:
                environment: prod
                serviceConnection: sc-prod
                approvalGroup: security-team
                resourceGroup: rg-myapp-prod
              strategy:
                runOnce:
                  deploy:
                    steps:
                      - script: echo "Pre-deploy checks"
                        displayName: 'Pre-flight'
# templates/deploy-job.yml — reads all four templateContext properties per job
parameters:
  - name: containerRegistry
    type: string
  - name: imageTag
    type: string
  - name: deployments
    type: deploymentList
    default: []

jobs:
  - ${{ each deploy in parameters.deployments }}:
    - deployment: ${{ deploy.deployment }}
      displayName: 'Deploy to ${{ deploy.templateContext.environment }}'
      # Azure DevOps environment — controls approval gates and audit log
      environment:
        name: ${{ deploy.templateContext.environment }}
        resourceName: myapp-${{ deploy.templateContext.environment }}
      variables:
        # Bridge templateContext values into runtime variables for script steps
        - name: RESOURCE_GROUP
          value: ${{ deploy.templateContext.resourceGroup }}
        - name: DEPLOY_ENV
          value: ${{ deploy.templateContext.environment }}
      pool:
        vmImage: ubuntu-latest
      strategy:
        runOnce:
          deploy:
            steps:
              - ${{ deploy.strategy.runOnce.deploy.steps }}
              - task: AzureCLI@2
                displayName: 'Deploy container'
                inputs:
                  azureSubscription: ${{ deploy.templateContext.serviceConnection }}
                  scriptType: bash
                  scriptLocation: inlineScript
                  inlineScript: |
                    az webapp config container set \
                      --resource-group "$(RESOURCE_GROUP)" \
                      --name "myapp-$(DEPLOY_ENV)" \
                      --docker-custom-image-name "${{ parameters.containerRegistry }}/myapp:${{ parameters.imageTag }}"

Without templateContext, routing each job to a different environment requires either hardcoding the environment name in the template or maintaining a parallel metadata array. With templateContext, the relationship between job identity and deployment target is explicit in the pipeline file.

Production Pattern 2 — Per-Job Conditional Step Injection

A templateContext boolean property can drive a compile-time ${{ if }} block inside the template to add or remove steps for that specific job. The caller annotates individual jobs as needing additional steps without modifying the template:

# pipeline.yml — only prod gets smoke tests
stages:
  - template: templates/deploy-job.yml
    parameters:
      deployments:
        - deployment: DeployDev
          templateContext:
            environment: dev
            serviceConnection: sc-dev
            runSmokeTests: false     # no smoke tests for dev
          strategy:
            runOnce:
              deploy:
                steps:
                  - script: echo "Deploy dev"

        - deployment: DeployProd
          templateContext:
            environment: prod
            serviceConnection: sc-prod
            runSmokeTests: true      # inject smoke test steps for prod
            smokeTestUrl: https://myapp.prod.example.com
          strategy:
            runOnce:
              deploy:
                steps:
                  - script: echo "Deploy prod"
# templates/deploy-job.yml — conditional step injection
parameters:
  - name: deployments
    type: deploymentList
    default: []

jobs:
  - ${{ each deploy in parameters.deployments }}:
    - deployment: ${{ deploy.deployment }}
      environment:
        name: ${{ deploy.templateContext.environment }}
      pool:
        vmImage: ubuntu-latest
      strategy:
        runOnce:
          deploy:
            steps:
              - ${{ deploy.strategy.runOnce.deploy.steps }}

              # Smoke tests are injected for this specific job at compile time
              - ${{ if deploy.templateContext.runSmokeTests }}:
                - script: |
                    echo "Running smoke tests against $SMOKE_URL"
                    ./smoke-tests.sh --url "$SMOKE_URL" --timeout 120
                  displayName: 'Smoke tests'
                  env:
                    SMOKE_URL: ${{ coalesce(deploy.templateContext.smokeTestUrl, 'http://localhost') }}

              - ${{ if deploy.templateContext.runSmokeTests }}:
                - task: PublishTestResults@2
                  displayName: 'Publish smoke test results'
                  condition: always()
                  inputs:
                    testResultsFiles: '**/smoke-results.xml'
                    testRunTitle: 'Smoke Tests — ${{ deploy.templateContext.environment }}'

The ${{ if }} condition evaluates at compile time — either the smoke test steps appear in the Expanded YAML for that job or they do not. Adding smoke tests to a new environment requires only adding runSmokeTests: true to that job’s templateContext: in the pipeline file.

Production Pattern 3 — Per-Job Variable Injection

The job-level variables: bridge makes templateContext values available to every step in a job:

# templates/deploy-job.yml — full variable bridge pattern
parameters:
  - name: deployments
    type: deploymentList
    default: []

jobs:
  - ${{ each deploy in parameters.deployments }}:
    - deployment: ${{ deploy.deployment }}
      environment:
        name: ${{ deploy.templateContext.environment }}
      variables:
        # All templateContext values the steps in this job need
        - name: DEPLOY_ENV
          value: ${{ deploy.templateContext.environment }}
        - name: RESOURCE_GROUP
          value: ${{ coalesce(deploy.templateContext.resourceGroup, 'rg-default') }}
        - name: NOTIFICATION_CHANNEL
          value: ${{ coalesce(deploy.templateContext.notificationChannel, 'deployments') }}
        - name: APPROVAL_GROUP
          value: ${{ deploy.templateContext.approvalGroup }}
      pool:
        vmImage: ubuntu-latest
      strategy:
        runOnce:
          deploy:
            steps:
              - ${{ deploy.strategy.runOnce.deploy.steps }}

              - script: |
                  echo "Environment: $(DEPLOY_ENV)"
                  echo "Resource group: $(RESOURCE_GROUP)"
                  echo "Notifying channel: $(NOTIFICATION_CHANNEL)"
                displayName: 'Deployment summary'

              - task: InvokeRestAPI@1
                displayName: 'Notify Slack'
                inputs:
                  connectionType: connectedServiceName
                  serviceConnection: sc-slack
                  method: POST
                  body: |
                    {"channel": "$(NOTIFICATION_CHANNEL)", "text": "Deployed to $(DEPLOY_ENV)"}

Variables declared in the job-level variables: block are readable by all steps in the job via $(VARIABLE_NAME) macro expansion. The compile-time expression resolves the templateContext value before the variable is declared at runtime.


templateContext with stageList

Stage-Level Metadata

templateContext works identically on stage definitions passed via a stageList parameter. Place it as a sibling of jobs: inside the stage block:

# pipeline.yml — stages carrying region metadata
stages:
  - template: templates/regional-deploy.yml
    parameters:
      deployStages:
        - stage: DeployUSEast
          templateContext:
            region: us-east
            complianceTier: standard
            notificationChannel: deployments-us
          jobs:
            - deployment: Deploy
              pool:
                vmImage: ubuntu-latest
              environment: us-east
              strategy:
                runOnce:
                  deploy:
                    steps:
                      - script: ./deploy.sh

        - stage: DeployEUWest
          templateContext:
            region: eu-west
            complianceTier: gdpr              # GDPR compliance tier — affects audit steps
            notificationChannel: deployments-eu
          jobs:
            - deployment: Deploy
              pool:
                vmImage: ubuntu-latest
              environment: eu-west
              strategy:
                runOnce:
                  deploy:
                    steps:
                      - script: ./deploy.sh

The iterating template accesses stage-level metadata via ${{ stage.templateContext.propertyName }}:

# templates/regional-deploy.yml
parameters:
  - name: deployStages
    type: stageList
    default: []

stages:
  - ${{ each stage in parameters.deployStages }}:
    - stage: ${{ stage.stage }}
      displayName: 'Deploy to ${{ stage.templateContext.region }}'
      variables:
        # Bridge stage-level metadata into runtime variables
        - name: DEPLOY_REGION
          value: ${{ stage.templateContext.region }}
        - name: COMPLIANCE_TIER
          value: ${{ stage.templateContext.complianceTier }}
      jobs:
        # Conditionally inject GDPR audit job for compliant stages
        - ${{ if eq(stage.templateContext.complianceTier, 'gdpr') }}:
          - job: GDPRAudit
            pool:
              vmImage: ubuntu-latest
            steps:
              - script: ./gdpr-audit.sh --region "$(DEPLOY_REGION)"
                displayName: 'GDPR compliance audit'

        - ${{ each job in stage.jobs }}:
          - ${{ job }}

Stage-level templateContext suits metadata that applies to the entire stage: geographic region, compliance tier, release track, or notification routing. Per-job variation within the stage uses job-level templateContext on jobs inside the stage’s jobs: list.

Production Pattern 4 — Multi-Tenant Regional Deployment

A platform team defines one stage template that handles any region. Callers pass a stageList where each stage carries templateContext with the region-specific configuration. Adding a new region requires one new entry in the pipeline file — the template is unchanged:

# pipeline.yml — three regional stages; each stage is fully self-describing
stages:
  - template: templates/tenant-deploy.yml
    parameters:
      config:                                    # shared settings (global object param)
        containerRegistry: myregistry.azurecr.io
        imageTag: $(Build.BuildId)
        appName: myplatform

      deployStages:                              # per-region metadata (templateContext)
        - stage: DeployAPAC
          templateContext:
            region: australiaeast
            tenantId: tenant-apac
            serviceConnection: sc-apac-prod
            approvalRequired: true
            smokeTestUrl: https://apac.myplatform.example.com
          jobs:
            - job: PreDeploy
              pool: { vmImage: ubuntu-latest }
              steps: [ script: echo "APAC pre-deploy" ]

        - stage: DeployEMEA
          templateContext:
            region: westeurope
            tenantId: tenant-emea
            serviceConnection: sc-emea-prod
            approvalRequired: true
            smokeTestUrl: https://emea.myplatform.example.com
          jobs:
            - job: PreDeploy
              pool: { vmImage: ubuntu-latest }
              steps: [ script: echo "EMEA pre-deploy" ]

        - stage: DeployAmericas
          templateContext:
            region: eastus
            tenantId: tenant-americas
            serviceConnection: sc-americas-prod
            approvalRequired: false
            smokeTestUrl: https://us.myplatform.example.com
          jobs:
            - job: PreDeploy
              pool: { vmImage: ubuntu-latest }
              steps: [ script: echo "Americas pre-deploy" ]
# templates/tenant-deploy.yml — single template handles any number of regions
parameters:
  - name: config
    type: object
    default:
      containerRegistry: ''
      imageTag: latest
      appName: myapp
  - name: deployStages
    type: stageList
    default: []

stages:
  - ${{ each stage in parameters.deployStages }}:
    - stage: ${{ stage.stage }}
      displayName: 'Deploy to ${{ stage.templateContext.region }}'
      variables:
        # Bridge stage templateContext values to runtime variables
        - name: DEPLOY_REGION
          value: ${{ stage.templateContext.region }}
        - name: TENANT_ID
          value: ${{ stage.templateContext.tenantId }}
      jobs:
        # Approval gate — injected only for stages where approvalRequired: true
        - ${{ if stage.templateContext.approvalRequired }}:
          - job: ApprovalGate
            pool: server                       # server job runs no agent
            timeoutInMinutes: 4320             # 3-day approval window
            steps:
              - task: ManualValidation@0
                displayName: 'Approve ${{ stage.templateContext.region }} deployment'
                inputs:
                  instructions: |
                    Review and approve deployment to ${{ stage.templateContext.region }}
                    Tenant: ${{ stage.templateContext.tenantId }}
                    Image: ${{ parameters.config.containerRegistry }}/${{ parameters.config.appName }}:${{ parameters.config.imageTag }}
                  onTimeout: reject

        # Inject the caller's jobs directly
        - ${{ each job in stage.jobs }}:
          - ${{ job }}

        # Deployment job — injected dynamically for every stage
        - deployment: Deploy_${{ stage.templateContext.tenantId }}
          displayName: 'Deploy ${{ parameters.config.appName }} to ${{ stage.templateContext.region }}'
          ${{ if stage.templateContext.approvalRequired }}:
            dependsOn: ApprovalGate
          environment:
            name: ${{ stage.templateContext.tenantId }}-${{ stage.templateContext.region }}
          pool:
            vmImage: ubuntu-latest
          strategy:
            runOnce:
              deploy:
                steps:
                  - task: AzureCLI@2
                    displayName: 'Deploy container to ${{ stage.templateContext.region }}'
                    inputs:
                      azureSubscription: ${{ stage.templateContext.serviceConnection }}
                      scriptType: bash
                      scriptLocation: inlineScript
                      inlineScript: |
                        az webapp config container set \
                          --resource-group "rg-$(TENANT_ID)-$(DEPLOY_REGION)" \
                          --name "${{ parameters.config.appName }}-$(TENANT_ID)" \
                          --docker-custom-image-name \
                            "${{ parameters.config.containerRegistry }}/${{ parameters.config.appName }}:${{ parameters.config.imageTag }}"

                  - script: |
                      curl -f "$SMOKE_URL/health" || exit 1
                    displayName: 'Smoke test'
                    env:
                      SMOKE_URL: ${{ stage.templateContext.smokeTestUrl }}

Adding a new region requires one new entry in the deployStages list. The approvalRequired, serviceConnection, tenantId, and region metadata travel with the stage. The template is unchanged.

Nesting templateContext — Jobs Inside Stages

A stage definition can carry stage-level templateContext while its jobs carry their own job-level templateContext. The two are independent and do not interfere:

# pipeline.yml — stage-level and job-level templateContext coexist
- template: templates/deploy.yml
  parameters:
    deployStages:
      - stage: DeployUSEast
        templateContext:
          region: us-east           # stage-level: applies to the whole stage
        jobs:
          - deployment: DeployFrontend
            templateContext:
              role: frontend        # job-level: applies to this job only
              port: 443
            environment: frontend-us
            strategy:
              runOnce:
                deploy:
                  steps:
                    - script: ./deploy-frontend.sh
          - deployment: DeployAPI
            templateContext:
              role: api
              port: 8080
            environment: api-us
            strategy:
              runOnce:
                deploy:
                  steps:
                    - script: ./deploy-api.sh
# templates/deploy.yml — reads both levels correctly
parameters:
  - name: deployStages
    type: stageList
    default: []

stages:
  - ${{ each stage in parameters.deployStages }}:
    - stage: ${{ stage.stage }}
      variables:
        - name: STAGE_REGION
          value: ${{ stage.templateContext.region }}    # stage-level
      jobs:
        - ${{ each deploy in stage.jobs }}:
          - deployment: ${{ deploy.deployment }}
            variables:
              - name: JOB_ROLE
                value: ${{ deploy.templateContext.role }}  # job-level
              - name: SERVICE_PORT
                value: ${{ deploy.templateContext.port }}
            environment: ${{ deploy.environment }}
            strategy: ${{ deploy.strategy }}

Do not duplicate stage-level properties in every job’s templateContext. If a value applies to all jobs in a stage, declare it on the stage’s templateContext and propagate it downward via the stage-level variables: block — jobs inherit those variables at runtime via $(VARIABLE_NAME).


templateContext vs. object Parameter — The Decision Framework

What Each Tool Is For

templateContext is for per-item metadata — properties that vary between individual jobs or stages in a list. Each item carries exactly the metadata it needs and nothing more.

An object parameter is for template-wide configuration — a single bundle that applies to the entire template invocation, shared equally by all items.

Confusing the two creates problems in either direction. A single flat object parameter that grows a new property every time a new per-job variation is needed becomes a sprawling schema with dozens of prefixed properties (devServiceConnection, stagingServiceConnection, prodServiceConnection). templateContext carrying global configuration — the container registry URL, the build number — forces every job entry to repeat the same value.

templateContextobject parameter
ScopePer item in a list (one job or stage)Entire template invocation
Declaration locationInside the job or stage definitionIn the calling pipeline’s parameters: block
Access syntax${{ job.templateContext.prop }} inside ${{ each }}${{ parameters.config.prop }} anywhere in the template
New item addedNo template change needed — new item brings its own metadataNo template change needed — shared config unchanged
New shared setting addedEdit every item’s templateContext: blockEdit the global config default once
Schema validationNone — fully unvalidatedNone — object type is unvalidated
Best use caseDeployment target, approval group, service connection, per-environment URLContainer registry, image tag, feature flags, org-wide settings

Combining Both

The two tools are complementary. A template can receive a global config object parameter for shared settings and a deploymentList where each job’s templateContext carries only the per-job overrides:

# pipeline.yml — global config + per-job templateContext
stages:
  - template: templates/deploy-jobs.yml
    parameters:
      config:                                      # shared: same for all jobs
        containerRegistry: myregistry.azurecr.io
        imageTag: $(Build.BuildId)
        buildNumber: $(Build.BuildNumber)
        notifyOnFailure: true

      deployments:                                 # per-job: unique per environment
        - deployment: DeployDev
          templateContext:
            environment: dev
            serviceConnection: sc-dev
            replicaCount: 1
          strategy:
            runOnce:
              deploy:
                steps:
                  - script: echo "Pre-deploy checks"

        - deployment: DeployProd
          templateContext:
            environment: prod
            serviceConnection: sc-prod
            replicaCount: 5
            runSmokeTests: true
          strategy:
            runOnce:
              deploy:
                steps:
                  - script: echo "Pre-deploy checks"
# templates/deploy-jobs.yml — reads both global config and per-job templateContext
parameters:
  - name: config
    type: object
    default:
      containerRegistry: ''
      imageTag: latest
      buildNumber: ''
      notifyOnFailure: false
  - name: deployments
    type: deploymentList
    default: []

jobs:
  - ${{ each deploy in parameters.deployments }}:
    - deployment: ${{ deploy.deployment }}
      environment:
        name: ${{ deploy.templateContext.environment }}
      pool:
        vmImage: ubuntu-latest
      strategy:
        runOnce:
          deploy:
            steps:
              - ${{ deploy.strategy.runOnce.deploy.steps }}

              - task: AzureCLI@2
                displayName: 'Deploy to ${{ deploy.templateContext.environment }}'
                inputs:
                  azureSubscription: ${{ deploy.templateContext.serviceConnection }}
                  scriptType: bash
                  scriptLocation: inlineScript
                  inlineScript: |
                    # Global config: shared registry and tag
                    IMAGE="${{ parameters.config.containerRegistry }}/myapp:${{ parameters.config.imageTag }}"
                    # Per-job templateContext: unique replica count per environment
                    REPLICAS="${{ deploy.templateContext.replicaCount }}"

                    az aks scale \
                      --resource-group "rg-myapp-${{ deploy.templateContext.environment }}" \
                      --name "aks-${{ deploy.templateContext.environment }}" \
                      --node-count "$REPLICAS"

              - ${{ if deploy.templateContext.runSmokeTests }}:
                - script: ./smoke-tests.sh
                  displayName: 'Smoke tests'

The config object carries the values that do not vary by deployment target. The templateContext carries the values that make each deployment unique. Neither grows unnecessarily when the other changes.

When Neither Is the Right Tool

Use a runtime condition: field with $[ ] expressions when the decision depends on a value that only exists at runtime — such as the result of a previous job, a release pipeline variable, or a quality gate score:

# Runtime condition — templateContext cannot evaluate this
- job: DeployProd
  dependsOn: QualityGate
  condition: and(succeeded('QualityGate'), eq(dependencies.QualityGate.outputs['gate.passed'], 'true'))
  steps:
    - script: ./deploy.sh

templateContext is a compile-time mechanism. It cannot read variables set by ##vso[task.setvariable] inside a running job, and it cannot react to runtime conditions. If the routing decision depends on a value that does not exist until a job or step executes, use condition: or dependsOn instead.

Never store secrets in templateContext. The content is plain text in source-controlled YAML. Service principal passwords, API keys, and connection strings belong in variable groups, Azure Key Vault references ($(az-keyvault-secret)), or service connections — not in templateContext: blocks that anyone with repository read access can see.

Decision flow:

  • Is the value known at pipeline-definition time (author time, not runtime)?
    • No → use condition: with $[ ] runtime expression
    • Yes → continue
  • Does the value vary per job or stage in the list?
    • Yes → templateContext
    • No → object parameter
  • Is the value a secret?
    • Yes → variable group or Key Vault; never templateContext or object param in plaintext

Hands-On Example: Multi-Environment Deployment with Per-Stage Approvals

Scenario: A platform team supports deployments to four environments: dev, test, staging, and prod. Each environment has a different Azure DevOps environment (with approval gates), a different service connection, and a different notification channel for deployment events. The prod environment additionally requires a smoke test job after deployment. The team wants a single reusable stage template driven by templateContext.

Prerequisites:

  • Four Azure DevOps environments created and named: dev, test, staging, prod
  • Four service connections named by convention: sc-dev, sc-test, sc-staging, sc-prod
  • Familiarity with stageList parameter type

Implementation:

Step 1 — Create templates/deploy-stage.yml with a stageList parameter:

# templates/deploy-stage.yml
parameters:
  - name: containerRegistry
    type: string
  - name: imageTag
    type: string
  - name: deployStages
    type: stageList
    default: []

stages:
  - ${{ each stage in parameters.deployStages }}:
    - stage: ${{ stage.stage }}
      displayName: 'Deploy — ${{ stage.templateContext.environment }}'
      variables:
        - name: DEPLOY_ENV
          value: ${{ stage.templateContext.environment }}
        - name: NOTIFICATION_CHANNEL
          value: ${{ coalesce(stage.templateContext.notificationChannel, 'deployments') }}
      jobs:
        # Deployment job — always present
        - deployment: DeployApp
          displayName: 'Deploy to ${{ stage.templateContext.environment }}'
          environment:
            name: ${{ stage.templateContext.environment }}
            resourceName: myapp-${{ stage.templateContext.environment }}
          pool:
            vmImage: ubuntu-latest
          strategy:
            runOnce:
              deploy:
                steps:
                  - task: AzureCLI@2
                    displayName: 'Deploy container image'
                    inputs:
                      azureSubscription: ${{ stage.templateContext.serviceConnection }}
                      scriptType: bash
                      scriptLocation: inlineScript
                      inlineScript: |
                        az webapp config container set \
                          --resource-group "rg-myapp-$(DEPLOY_ENV)" \
                          --name "myapp-$(DEPLOY_ENV)" \
                          --docker-custom-image-name \
                            "${{ parameters.containerRegistry }}/myapp:${{ parameters.imageTag }}"
                        echo "Deployed ${{ parameters.imageTag }} to $(DEPLOY_ENV)"

                  - task: InvokeRestAPI@1
                    displayName: 'Notify ${{ stage.templateContext.notificationChannel }}'
                    inputs:
                      connectionType: connectedServiceName
                      serviceConnection: sc-teams-webhook
                      method: POST
                      body: |
                        {"text": "Deployed to $(DEPLOY_ENV) — build ${{ parameters.imageTag }}"}

        # Smoke test job — injected only when runSmokeTests: true
        - ${{ if stage.templateContext.runSmokeTests }}:
          - job: SmokeTests
            displayName: 'Smoke tests — ${{ stage.templateContext.environment }}'
            dependsOn: DeployApp
            pool:
              vmImage: ubuntu-latest
            steps:
              - script: |
                  echo "Running smoke tests against $SMOKE_URL"
                  for i in 1 2 3 4 5; do
                    curl -sf "$SMOKE_URL/health" && break || sleep 10
                  done
                  curl -f "$SMOKE_URL/health" || exit 1
                displayName: 'Health check (with retry)'
                env:
                  SMOKE_URL: ${{ stage.templateContext.smokeTestUrl }}

              - task: PublishTestResults@2
                displayName: 'Publish smoke results'
                condition: always()
                inputs:
                  testResultsFiles: '**/smoke-results.xml'
                  testRunTitle: 'Smoke — ${{ stage.templateContext.environment }}'

Step 2 — Define four stages in the calling pipeline:

# azure-pipelines.yml — 4 stages with templateContext
trigger:
  - main

stages:
  - template: templates/deploy-stage.yml
    parameters:
      containerRegistry: myregistry.azurecr.io
      imageTag: $(Build.BuildId)
      deployStages:
        - stage: Dev
          templateContext:
            environment: dev
            serviceConnection: sc-dev
            notificationChannel: deployments-dev
            runSmokeTests: false
          jobs: []           # template provides the deployment job; no additional jobs needed

        - stage: Test
          dependsOn: Dev
          templateContext:
            environment: test
            serviceConnection: sc-test
            notificationChannel: deployments-test
            runSmokeTests: false
          jobs: []

        - stage: Staging
          dependsOn: Test
          templateContext:
            environment: staging
            serviceConnection: sc-staging
            notificationChannel: deployments-staging
            runSmokeTests: false
          jobs: []

        - stage: Prod
          dependsOn: Staging
          templateContext:
            environment: prod
            serviceConnection: sc-prod
            notificationChannel: deployments-prod
            runSmokeTests: true
            smokeTestUrl: https://myapp.prod.example.com
          jobs: []

Verification:

  • Pipeline graph shows four deployment stages with names Dev, Test, Staging, Prod.
  • Expanded YAML shows the SmokeTests job present in the Prod stage and absent from the other three.
  • Each deployment task references the correct service connection for its stage.
  • Adding a uat stage requires one new entry in the deployStages list — zero changes to templates/deploy-stage.yml.

Best Practices & Optimization

Do:

  • Use templateContext for any property that varies per job or stage — keep variation data co-located with the item it describes.
  • Bridge templateContext values into runtime steps via the job-level variables: injection pattern — it is the only reliable way to make compile-time metadata available to executing scripts.
  • Use coalesce() for optional properties: ${{ coalesce(job.templateContext.notificationChannel, 'deployments') }}.
  • Document the expected templateContext schema in the template file’s parameters: block comments — it is the only discoverable reference for callers, since templateContext has no schema validation.
  • Combine templateContext with a global object parameter: templateContext for per-item variation, object for template-wide shared settings.

Don’t:

  • Put secrets in templateContext — the content is plain text in source-controlled YAML visible to anyone with repository read access; use variable groups or Azure Key Vault.
  • Access templateContext from inside a steps: block — it does not exist at runtime; use the variable injection or env: block pattern.
  • Use templateContext on jobs defined directly in the pipeline root YAML (not inside a parameter) — it triggers an “Unexpected value ’templateContext’” parse error.
  • Replicate stage-level templateContext values into every job in that stage — inject once at the stage level via variables: and let jobs inherit.

Performance:

templateContext adds no runtime overhead. It is compiled away before the pipeline executes — no cost to adding many properties. However, deeply nested templateContext structures (objects containing objects) increase the YAML parser’s memory footprint during Phase 2. Keep templateContext flat (scalar values, not nested mappings) to stay well within the 20 MB parsing memory limit.

Security:

templateContext content is visible in the repository YAML file — treat it as public configuration, not a credentials store. A templateContext property used in a ${{ if }} condition controls pipeline structure at compile time. A Facade template with values: constraints on routing parameters provides the validation layer that templateContext itself cannot.


Troubleshooting Common Issues

Issue 1: “Unexpected value ’templateContext’” parse error

Cause: templateContext was added to a job defined directly in the pipeline root YAML (not inside a list parameter passed to a template), or the pipeline runs on Azure DevOps Server older than version 2022.

Solution: Move the job into a list parameter and process it with a ${{ each }} loop in a template file. For Server environments, verify the installed version is 2022 or later — the “Unexpected value” error is the exact error both scenarios produce.


Issue 2: ${{ deploy.templateContext.myProp }} resolves to an empty string

Cause: The property name is misspelled, the job entry in the pipeline file omits the templateContext: block entirely, or the property is absent from that specific job’s templateContext while present on others.

Solution: Use coalesce to handle absent properties gracefully and expose missing required properties with a validation block:

# Handle optional property with fallback
displayName: 'Deploy to ${{ coalesce(deploy.templateContext.environment, "MISSING-ENV") }}'

# Validate required property with a failing step
- ${{ if not(deploy.templateContext.serviceConnection) }}:
  - script: |
      echo "##vso[task.logissue type=error]templateContext.serviceConnection is required for deployment ${{ deploy.deployment }}"
      exit 1
    displayName: 'ERROR: Missing required templateContext.serviceConnection'

Issue 3: A templateContext value is needed inside a script step but is not accessible

Cause: templateContext does not exist at the runtime phase when steps: execute.

Solution: Use one of the three bridge patterns. For values needed by multiple steps, inject at the job level:

- ${{ each deploy in parameters.deployments }}:
  - deployment: ${{ deploy.deployment }}
    variables:
      # Compile-time extraction into a runtime pipeline variable
      - name: TARGET_ENV
        value: ${{ deploy.templateContext.targetEnvironment }}
    strategy:
      runOnce:
        deploy:
          steps:
            - script: echo "Deploying to $(TARGET_ENV)"   # readable at runtime

Issue 4: Stage-level templateContext values are not accessible inside the stage’s jobs

Cause: templateContext on a stage is readable in the template at the stage iteration level (${{ stage.templateContext.prop }}). It is not automatically available inside jobs defined within that stage.

Solution: At stage iteration time, inject the stage-level templateContext values into the stage’s variables: block. Jobs in the stage inherit those variables at runtime:

- ${{ each stage in parameters.deployStages }}:
  - stage: ${{ stage.stage }}
    variables:
      # Stage-level variables inherited by all jobs in this stage
      - name: STAGE_REGION
        value: ${{ stage.templateContext.region }}
      - name: STAGE_TENANT
        value: ${{ stage.templateContext.tenantId }}
    jobs:
      - ${{ each job in stage.jobs }}:
        - ${{ job }}
          # Jobs can now read $(STAGE_REGION) and $(STAGE_TENANT) at runtime

Issue 5: Indentation of templateContext properties causes a parse error

Cause: The templateContext: block is not a parse error source itself, but its children must be indented exactly one level deeper. YAML indentation errors under templateContext: are surfaced as “mapping values are not allowed here” or “could not find expected ‘:’” errors pointing at the wrong line.

Solution: Validate indentation with a YAML linter before committing. The correct structure:

- deployment: DeployDev  # 2 spaces (list item under jobs:)
  templateContext:       # 2 spaces (sibling of strategy:, pool:)
    environment: dev     # 4 spaces (child of templateContext:)
    serviceConnection: sc-dev  # 4 spaces — same level as environment
  strategy:              # 2 spaces (sibling of templateContext:)
    runOnce:

Key Takeaways

  1. templateContext is a compile-time escape hatch for attaching custom metadata to jobList, deploymentList, and stageList entries. Azure DevOps recognizes it as a reserved property, bypassing schema validation without triggering a parse error.

  2. Access scope is strictly compile-time: ${{ deploy.templateContext.prop }} works inside a ${{ each }} loop in a template file; it does not work inside steps: at runtime.

  3. The canonical bridge to runtime is the job-level variables: injection pattern — extract templateContext values at compile time into pipeline variables, then consume them via $(VARIABLE_NAME) in scripts.

  4. templateContext is for per-item variation; a global object parameter is for template-wide configuration. Combining both produces a template interface that stays stable as new environments or jobs are added.

  5. templateContext is stripped from the Expanded YAML completely. To debug what a templateContext property resolved to, add a diagnostic script step that echoes the injected variable value: script: echo "$(TARGET_ENV)".

Next steps:

  • Read the article on Escaping Parameter Hell in this series for how templateContext fits into a larger template architecture alongside Config Object and Facade patterns.
  • Read the article on Advanced ${{ each }} Looping for techniques that combine ${{ each }} with templateContext to generate dynamic stage matrices from complex nested objects.
  • Apply the variable injection bridge pattern to any existing template that uses templateContext values inside scripts — the bridge is the only safe access method.

Sources