Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

GitLab logo

Are you repeating the same lines in several jobs? extends and YAML anchors let you factor out your configuration. Define it once, reuse it everywhere.

The DRY principle

DRY = Don't Repeat Yourself. It is a basic principle of programming: if you copy-paste code, it should probably be factored out.

In CI/CD, if you have 5 jobs with the same Docker image and the same cache, you have 5 places to edit when you change versions. With extends, you edit a single place.

This guide is for you if...

Read this guide through the section that matches your need. If you duplicate configuration (same image, same cache in five jobs), start with extends, job inheritance. To inherit while changing some parts, see overriding properties. If you know YAML, the anchors cover the advanced cases. And if you hesitate between the two, the comparison extends vs anchors vs !reference settles it.

What you will learn

By the end of this module, you will be able to:

  • Use extends: make a job inherit from a hidden template
  • Create hidden jobs: the . convention for templates
  • Override properties: customize the inheritance without rewriting everything
  • Understand merging: dictionaries merge, lists replace
  • Use YAML anchors: &, * and <<: for the advanced cases
  • Pick the right tool: extends vs anchors vs !reference

Prerequisites

Before going on, make sure you are comfortable with:

extends: job inheritance

The diagram below shows what the child job takes from the parent, and what it can redefine for itself. The dotted arrows mark the properties inherited as they are (greyed out in the child), the solid arrows the ones that build_prod overrides with its own value.

Inheritance with extends: build_prod inherits image and before_script from .base_job, and overrides variables (ENV goes from dev to prod) and script..base_jobhidden job, never runsbuild_prodrunsimage: node:20before_script: [npm ci]variables: {ENV: dev}script: [npm test]image: node:20before_script: [npm ci]variables: {ENV: prod}script: [npm run build]inheritedinheritedoverriddenoverridden
.base_job is a hidden job: it does not run, only build_prod does. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Basic syntax

A job can inherit from another job with extends. The parent job is usually hidden (prefixed with .) so that it does not run itself:

# Hidden job (does not run)
.base_job:
image: python:3.11@sha256:c7220863385ee39fb6d822da81f4469d0cd33ff893d92ce94105e5c3f4b95fe2
before_script:
- pip install -r requirements.txt
# Job that inherits
test:
extends: .base_job
script:
- pytest

What happens exactly?

GitLab "merges" the parent job and the child job. It is as if you had written:

test:
image: python:3.11@sha256:c7220863385ee39fb6d822da81f4469d0cd33ff893d92ce94105e5c3f4b95fe2 # Inherited from .base_job
before_script: # Inherited from .base_job
- pip install -r requirements.txt
script: # Defined by test
- pytest

The test job automatically inherits:

  • image: python:3.11
  • before_script: [pip install -r requirements.txt]

And it defines its own script.

Hidden jobs (the . convention)

Jobs whose name starts with . are templates, they never run. Their only role is to serve as a model for other jobs.

.template: # Does not run (the name starts with .)
image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
my_job: # Runs (normal name)
extends: .template
script: echo "Hello"

Why use hidden jobs?

  1. Avoid pollution: without the ., GitLab would try to run the template as a real job
  2. Clarity: seeing .nodejs_base, you immediately understand it is a template
  3. Convention: everybody uses this notation, so your code is readable by others

Multiple inheritance

A job can inherit from several templates at once. This is useful to compose behaviors:

.base:
image: node:18@sha256:c6ae79e38498325db67193d391e6ec1d224d96c693a8a4d943498556716d3783
variables:
ENV: production
.with_cache:
cache:
paths:
- .npm/
build:
extends:
- .base # First: provides image + variables
- .with_cache # Second: adds the cache
script: npm run build

build inherits from .base AND .with_cache. The final result:

build:
image: node:18@sha256:c6ae79e38498325db67193d391e6ec1d224d96c693a8a4d943498556716d3783 # From .base
variables:
ENV: production # From .base
cache:
paths:
- .npm/ # From .with_cache
script: npm run build # Defined locally

A template can itself inherit from another template: the inheritance chains. GitLab accepts up to 11 levels of inheritance, but the documentation recommends no more than 3: beyond that, you have to climb the whole chain to find out where a value comes from.

Overriding properties

Inheritance would be limited if you could not customize. The child job can override (replace) any property inherited from the parent:

.node_base:
image: node:18@sha256:c6ae79e38498325db67193d391e6ec1d224d96c693a8a4d943498556716d3783
script:
- npm test
variables:
NODE_ENV: development
# Overrides several properties
build_prod:
extends: .node_base
image: node:20@sha256:8f693eaa7e0a8e71560c9a82b55fd54c2ae920a2ba5d2cde28bac7d1c01c9ba5 # Replaces node:18 with node:20
script:
- npm run build # Replaces npm test with npm run build
variables:
NODE_ENV: production # Replaces development with production
# Keeps almost everything, changes only the script
test:
extends: .node_base
script:
- npm run lint # Replaces the script
- npm test

What is overridden vs what is inherited:

Property in build_prodValueSource
imagenode:20Overridden (was node:18)
scriptnpm run buildOverridden (was npm test)
variables.NODE_ENVproductionOverridden (was development)

Merging arrays: the classic trap

Here is the classic trap with extends: arrays (lists) are completely replaced, not merged. The GitLab documentation words it this way: extends merges hashes (tables), but not arrays (lists).

.base:
script:
- echo "step 1"
- echo "step 2"
job:
extends: .base
script:
- echo "only this" # WARNING: REPLACES completely, does not add to it!

Result: job runs only echo "only this". The "step 1" and "step 2" steps have disappeared.

How do you add steps without replacing?

There are two solutions. The first: put the extra command in a different key from the parent's. Here the parent has no after_script, so the child job can add one without losing anything from the parent:

.base:
before_script:
- echo "setup" # Always runs first
script:
- echo "main" # The main script
job:
extends: .base
after_script:
- echo "cleanup" # Key absent from the parent: added at the end

Execution order: setup → main → cleanup

Be careful: before_script is also a list. If job redefined it, the parent's version would be lost, exactly as with script. The second solution, valid for any list, is !reference, presented below: it copies the parent's list before you add your own commands.

Merging dictionaries

Dictionaries merge deeply:

.base:
variables:
VAR1: "value1"
VAR2: "value2"
job:
extends: .base
variables:
VAR2: "overridden" # Overrides VAR2
VAR3: "value3" # Adds VAR3
# Result: VAR1=value1, VAR2=overridden, VAR3=value3

YAML anchors

Anchors are a native YAML feature: they work in any YAML file, not only in a .gitlab-ci.yml. Their syntax is detailed in the guide Anchors, aliases and multiple documents in YAML (in French); this section keeps only what serves a .gitlab-ci.yml.

An anchor exists only in its own file. GitLab reads each YAML file separately, then assembles the include files. An anchor defined in an included file is therefore not found from the file that includes it, and the reverse is true as well. The hidden job that serves as the source of a *name reference must stay in the same file as the jobs that use it. To share across files, use extends or !reference, compared below.

Syntax: three symbols to know

SymbolWhat it does
&nameDefines an anchor (like "copy")
*nameReferences the anchor (like "paste")
<<:Merges the content into a dictionary

A concrete example

Here is how to use anchors to avoid repetition:

# 1. Define an anchor with &
.job_template: &job_config # &job_config = "I name this block"
image: ruby:3.0@sha256:dcb8a8a6ee981f949da505cb9f4f15cde696e7bcb088955bc1f92f23a4cd7a72
services:
- postgres:14@sha256:156f0b253fd61366d5fc2107ad45955027d5612f695a8436ce20167f3fa79bff
# 2. Use the anchor with << and *
test1:
<<: *job_config # "Merge job_config here"
script: ruby test1.rb
test2:
<<: *job_config # Same configuration, different script
script: ruby test2.rb

Equivalent without anchors (what you would have had to write otherwise):

test1:
image: ruby:3.0@sha256:dcb8a8a6ee981f949da505cb9f4f15cde696e7bcb088955bc1f92f23a4cd7a72
services:
- postgres:14@sha256:156f0b253fd61366d5fc2107ad45955027d5612f695a8436ce20167f3fa79bff
script: ruby test1.rb
test2:
image: ruby:3.0@sha256:dcb8a8a6ee981f949da505cb9f4f15cde696e7bcb088955bc1f92f23a4cd7a72
services:
- postgres:14@sha256:156f0b253fd61366d5fc2107ad45955027d5612f695a8436ce20167f3fa79bff
script: ruby test2.rb

The anchors saved you 4 lines of duplication.

Anchors on values

An anchor is not limited to job blocks: it works on any scalar value, which lets you declare a string once and reuse it wherever it is expected.

variables:
DATABASE_URL: &db_url "postgres://localhost:5432/app"
job1:
variables:
DB: *db_url
job2:
variables:
DATABASE: *db_url

Combining several anchors

Several anchors can be merged into the same job. Order matters, and it is the opposite of extends: for a key present in two anchors, it is the first one in the <<: [*a, *b] list that wins, and a key written in the job itself wins over all the anchors. The merge is also shallow: if both anchors carry a cache: table, the first one replaces it entirely instead of merging it key by key.

.base_config: &base
image: node:18@sha256:c6ae79e38498325db67193d391e6ec1d224d96c693a8a4d943498556716d3783
.cache_config: &cache
cache:
paths:
- .npm/
build:
<<: [*base, *cache] # Merges both
script: npm run build

Extending variables

The variables block merges like any other mapping. A job can therefore inherit from a common set while adding its own, without having to copy the inherited values.

variables: &global_vars
VAR1: "value1"
VAR2: "value2"
job:
variables:
<<: *global_vars
VAR3: "value3" # Adds a variable

extends vs anchors vs !reference

AspectextendsAnchors!reference
ReadabilityClearerCryptic syntaxExplicit
Merge mappingsYes, deeplyShallowNo (insertion)
Reuse listsNo, the parent's list is overwrittenNo, the list is overwrittenYes
Multi-fileYes, with includeNo, same file onlyYes, with include
GitLab-specificYesNo (YAML)Yes
Recommended forComplete jobsSimple valuesFragments (rules, script)

!reference: reusing fragments

GitLab provides !reference to reuse configuration fragments (lists, mappings) without the limitations of anchors:

.security_rules:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
.test_script:
script:
- npm test
- npm run coverage
build:
script:
- npm run build
rules:
- !reference [.security_rules, rules] # Reuses the rules
test:
script:
- !reference [.test_script, script] # Reuses the script
- npm run e2e # Adds a command
rules:
- !reference [.security_rules, rules]

Practical examples

These two configurations can be transposed directly. They show the most common case, a shared base declined into several variants of the same task.

Node.js base with variants

variables:
NODE_VERSION: "18" # Default value, can be overridden
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm" # npm archive cache, inside the project
.node_base:
image: "node:${NODE_VERSION}"
cache:
key:
files:
- package-lock.json
paths:
- .npm/
before_script:
- npm ci --prefer-offline
build:
extends: .node_base
script: npm run build
artifacts:
paths:
- dist/
test:
extends: .node_base
script: npm test
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
lint:
extends: .node_base
script: npm run lint
allow_failure: true

Deployment jobs

.deploy_base:
image: alpine:3.18@sha256:de0eb0b3f2a47ba1eb89389859a9bd88b28e82f5826b6969ad604979713c2d4f
before_script:
- apk add --no-cache curl
script:
- ./deploy.sh "$ENVIRONMENT"
deploy_staging:
extends: .deploy_base
variables:
ENVIRONMENT: staging
environment:
name: staging
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
deploy_production:
extends: .deploy_base
variables:
ENVIRONMENT: production
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual

Common mistakes

The four mistakes below are classic traps of this technique. The first two are silent: an expected job does not appear, or commands disappear without any message. The last two block the creation of the pipeline with an explicit error.

1. The job does not run

Symptom: Your job does not appear in the pipeline.

Cause: Its name starts with ., so it is a hidden job (template).

Solution: Remove the . from the name, or create a job that extends this template.

# ❌ Will never run
.build:
script: npm run build
# ✅ Will run
build:
script: npm run build

2. Script replaced instead of extended

Symptom: The parent's commands have disappeared.

Cause: Arrays (lists) do not merge, they are replaced.

Solution: Redefine the whole script, or copy the parent's list with !reference [.base, script]. A key absent from the parent, like after_script in the example above, is added without overwriting anything.

3. extends: unknown job

Symptom: GitLab refuses to create the pipeline.

Cause: The parent job does not exist (typo, file not included).

Solution: Check:

  • The exact name of the parent job (with the . if it is a template)
  • That the file containing the parent is actually included (include:)

4. Anchor not found

Symptom: YAML error when the pipeline is created.

Cause: You reference an anchor (*name) before defining it (&name), or the anchor is defined in another file (an include file).

Solution: Always define the anchor before using it, in the same file. If the hidden job lives in an included file, replace the anchor with extends.

# ❌ Error: anchor used before being defined
job:
<<: *config # Error! config does not exist yet
.template: &config
image: alpine:3.18@sha256:de0eb0b3f2a47ba1eb89389859a9bd88b28e82f5826b6969ad604979713c2d4f
# ✅ Correct: anchor defined first
.template: &config
image: alpine:3.18@sha256:de0eb0b3f2a47ba1eb89389859a9bd88b28e82f5826b6969ad604979713c2d4f
job:
<<: *config # OK, config exists

Hands-on lab

The lab "Stop repeating the pipeline, without changing what it does" replays this guide on a GitLab CE forge of your own, set up on a virtual machine with its runner and its registry, rather than on gitlab.com: no minute quota, no identity verification, and a project you can break as often as you need.

Two jobs declare the same image and the same cache, word for word. You factor them with a hidden job extended by extends (sections "extends: job inheritance" and "Overriding properties") and a YAML anchor merged by <<: on a block of variables (section "Extending variables"), keeping the behaviour exactly as it was. It is the only lab of the training graded on the file: a factorisation resolved by GitLab leaves no trace in the pipeline state, so the grading checks that the file carries an extended hidden job and a merged anchor, then that the jobs still run and that the coverage is still reported.

One trap this page does not show: coverage: belongs to the job, not to the cache or to the image. If you move the test job into a hidden job and forget that line, the pipeline stays green and the coverage disappears without a word. The coverage: keyword is described in Quality reports.

The lab starts with dsoxlab run gl-13-factoriser-le-yaml, after dsoxlab provision prepares the virtual machine, its runner and its registry. Installing DSOXLab and its prerequisites are described in the DSOXLab documentation.

Key takeaways

  1. extends provides job inheritance, prefer it to anchors
  2. Hidden jobs (.name) do not run, they serve as templates
  3. Mappings merge, scalars and arrays are overwritten
  4. Multiple inheritance: the last one wins in case of conflict; 3 levels of inheritance recommended, 11 at most
  5. Anchors (&, *, <<:) for advanced cases, valid in their own file only; with <<: [*a, *b], the first anchor wins
  6. !reference to reuse fragments (rules, script) between jobs
  7. Combine with include to share between projects, as the lesson include and templates details

Test your knowledge

Knowledge check

Check what you have learned with this interactive quiz

10 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn