Reading YAML Anchors in CI Files and Merge Key Gotchas
How to expand anchors and merge keys in CI files to see the effective job, and the yq 4.54.1 merge-key order bug that gave retries 2 where the spec says 5.

CI files grow. A pipeline starts with three jobs and ends with thirty, most of them variations on the same four lines, and the way people avoid copy-paste is anchors and merge keys. They work, and they hide the effective configuration: to know what a job runs, you have to follow the alias back to its anchor, apply the merge and remember which key wins. This post is about reading such files, with a technique that makes the effective job visible, and about a disagreement between tools that can change what a file means. It belongs to the cluster on YAML for infrastructure files developers edit weekly. The mechanics of anchors are in YAML anchors, aliases and merge keys.
The three pieces
A CI file with anchors uses three YAML features.
An anchor,
&name, labels a node:defaults: &defaults {...}.An alias,
*name, reuses that node somewhere else.A merge key,
<<: *name, copies the keys of a mapping into the current mapping.
defaults: &defaults
image: python:3.13
retries: 2
job:
<<: *defaults
retries: 5Read it as: job has everything in defaults, with retries set to 5 instead. The rule in the YAML merge key specification is that keys written explicitly in the mapping win over merged ones. All three libraries we tried agreed on this file:
PyYAML: {'image': 'python:3.13', 'retries': 5}
ruamel: {'image': 'python:3.13', 'retries': 5}
yq default: {"image":"python:3.13","retries":5}A disagreement about order
Now put the explicit key before the merge key, which is a legitimate way to write the same mapping:
defaults: &defaults
image: python:3.13
retries: 2
job:
retries: 5
<<: *defaultsBy the merge key rule, the result is the same: explicit keys win wherever the merge key appears. The tools did not all agree:
PyYAML: {'image': 'python:3.13', 'retries': 5}
ruamel: {'image': 'python:3.13', 'retries': 5}
yq default: {"retries":2,"image":"python:3.13"}
yq to-spec flag: {"retries":5,"image":"python:3.13"}yq 4.54.1 returned retries: 2 by default: the merged value overrode the explicit one because the merge key came later. With --yaml-fix-merge-anchor-to-spec=true it returned 5, matching the libraries. The tool says as much in a warning it prints: --yaml-fix-merge-anchor-to-spec is false; causing merge anchors to override the existing values which isn't to the yaml spec. This flag will default to true in late 2025. Newer versions may have changed the default; check the version you run.
What this means in practice: if you use yq to inspect or to edit CI files, and the file puts merge keys after explicit keys, the tool may show you a configuration different from the one the CI system parses. A reviewer trusting yq output would approve a pull request for a job that actually runs with a different retry count. Two defenses. Set the flag in every yq invocation you script, or upgrade to a release where it is the default. And write merge keys first, which is the convention almost everyone follows and the layout all parsers handle the same way.
Expand before you read
The reliable way to read a file with anchors is not to simulate the merge in your head, but to ask a tool to expand it. yq does that with explode:
yq --yaml-fix-merge-anchor-to-spec=true 'explode(.) | del(.".postgres")' .gitlab-ci.ymlOn the GitLab-style file from GitLab CI YAML services for database tests, it printed the effective job:
migrations:
services:
- name: postgres:18
alias: db
variables:
POSTGRES_PASSWORD: ci-only
PGHOST: db
PGUSER: postgres
PGPASSWORD: ci-only
stage: test
image: postgres:18
script:
- for f in migrations/*.sql; do psql -v ON_ERROR_STOP=1 -f "$f"; done
- psql -v ON_ERROR_STOP=1 -f tests/check_orders.sqlThe anchors are gone and every key is present where the platform will see it. del(.".postgres") removed the hidden job that only existed to carry the anchor. Make this a habit in code review: for any change to a file with anchors, expand the old and new versions and diff them, as in reviewing YAML config changes in a pull request. A one-line change to the anchored node can change every job that merges it, and the text diff shows one line. A script that expands both files and prints changed paths shows all of them.
The shallow merge
A merge key copies top-level keys only. If both the anchor and the job define variables, the job's replaces the anchor's entirely:
.base: &base
variables: {A: "1", B: "2"}
job:
<<: *base
variables: {C: "3"}The effective job has variables: {C: "3"}, not A, B and C. This is the single most common surprise with CI anchors, and it is a property of YAML. GitLab's extends keyword merges hashes deeply, as covered in the GitLab post, which is why that platform offers it. For lists, nothing merges: a list defined in the job replaces the anchored list. When you need to combine, either restructure so the pieces are separate keys or use the platform's own composition feature.
Does the platform support anchors
Anchors are a YAML feature, and a CI platform decides whether its parser accepts them and how it treats merge keys. GitLab CI documents anchors and merge keys and recommends them for reuse. GitHub Actions did not support anchors for a long time; GitHub announced support later, and actionlint 1.7.12 accepted a workflow in which two jobs shared an env mapping through &env and *env, exiting 0. That tells you the linter's parser accepts them. It does not tell you what GitHub's parser does with your repository today, so check the current GitHub documentation before relying on it, and run the workflow once. Other systems (CircleCI, Bitbucket Pipelines, Azure Pipelines) each have their own rules, and some offer their own reuse mechanism (CircleCI's commands and orbs, for example) in preference to YAML anchors. The principle: anchors are portable YAML, and support is a platform decision, so test on the platform.
Keeping anchored files readable
Anchors earn their keep for four or five repeated lines and cost readability beyond that. Some habits help.
Name anchors for what they are.
&postgres_servicebeats&a.Keep the anchor near the top under a dot-prefixed hidden key so readers find it.
Put the merge key first in each mapping that uses one.
Comment the intent.
# jobs that need a databasesaves a search. Comments that help the next person reading your YAML has more.Prefer the platform's reuse (templates,
extends, reusable workflows) when it exists, since it gives better error messages and tooling than raw YAML.Lint the expanded form.
yamllintsees the anchored text. A schema check on the expanded output tests what the platform will run.
When anchors make a CI file harder to read
More than two levels of reuse. An alias to an anchor that merges another anchor makes the effective job a puzzle. Flatten it.
Many jobs differ in small ways. If every job overrides three of six keys, the anchor saves little and hides the structure. Write them out.
People edit with tools that rewrite YAML. Some editors and bots expand anchors on save. Check what your tooling does to an anchored file, as in round tripping YAML without losing comments.
The platform's parser differs from your tools'. If any link in the chain, a linter, a bot or the platform, treats merge keys differently, write the file so the difference cannot matter: no duplicate keys across a merge, merge keys first.
The merge key behavior is defined in the YAML merge key language-independent type, and explode in the yq documentation. Tidy a CI file with the YAML formatter.


