Skip to content
datahacks.dev
YAML

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.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the YAML logo.

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.

YAML
defaults: &defaults
  image: python:3.13
  retries: 2
job:
  <<: *defaults
  retries: 5

Read 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:

Text
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:

YAML
defaults: &defaults
  image: python:3.13
  retries: 2
job:
  retries: 5
  <<: *defaults

By the merge key rule, the result is the same: explicit keys win wherever the merge key appears. The tools did not all agree:

Text
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:

Shell
yq --yaml-fix-merge-anchor-to-spec=true 'explode(.) | del(.".postgres")' .gitlab-ci.yml

On the GitLab-style file from GitLab CI YAML services for database tests, it printed the effective job:

Text
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.sql

The 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:

YAML
.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_service beats &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 database saves 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. yamllint sees 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.