YAML Cheatsheet
Merge Keys
Use this YAML reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
What << does
<<: *base copies every key from the aliased mapping into the current mapping.
Unlike a bare alias, the result is a new mapping you can add to and override.
defaults: &defaults
adapter: postgres
host: localhost
port: 5432
dev:
<<: *defaults
database: dev_db
prod:
<<: *defaults
host: db.internal
database: prod_dbparses to:
{"defaults": {"adapter": "postgres", "host": "localhost", "port": 5432},
"dev": {"adapter": "postgres", "host": "localhost",
"port": 5432, "database": "dev_db"},
"prod": {"adapter": "postgres", "host": "db.internal",
"port": 5432, "database": "prod_db"}}prod.host is db.internal. An explicit key always wins over a merged one,
regardless of whether it appears before or after the <<: line.
Precedence
Three levels, highest first:
- Keys written explicitly in this mapping.
- Keys from the first alias in a multi-merge list.
- Keys from later aliases in that list.
a: &a {x: 1, y: 1}
b: &b {y: 2, z: 2}
merged:
<<: [*a, *b]
z: 99parses to {"merged": {"y": 1, "z": 99, "x": 1}}. Read that carefully:
xcomes from*aonly.yis 1, from*a, because*ais earlier in the list and earlier wins.zis 99, the explicit key, beating*b.
This is the opposite of the CSS or Object.assign intuition, where later wins.
In a merge-key list, earlier wins.
The forms
| Form | Meaning |
|---|---|
<<: *base | merge one mapping |
<<: [*a, *b] | merge several, earlier takes precedence |
<<: {x: 1} | merge an inline mapping, no anchor needed |
<<: *a twice in one mapping | a duplicate key, parser dependent |
Only mappings can be merged. <<: *some_list is an error, and there is no merge
for sequences at all. Concatenating two lists is not something YAML can do.
Merging is shallow
<< copies top-level keys. A nested mapping is copied by reference, so it is
not merged recursively.
base: &base
db:
host: localhost
port: 5432
override:
<<: *base
db:
host: prod.internal # port 5432 is GONEoverride.db is {"host": "prod.internal"}. The whole db mapping was replaced,
not deep-merged. Anchor the inner mapping too if you need that:
x-db: &db host: localhost port: 5432 override: db: <<: *db host: prod.internal # {"host": "prod.internal", "port": 5432}
Support is not universal
Merge keys were a YAML 1.1 type, defined at
tag:yaml.org,2002:merge. They were not carried into the YAML 1.2 core schema,
so a strict 1.2 parser treats << as an ordinary string key.
| Parser | <<: support |
|---|---|
PyYAML safe_load | yes |
ruamel.yaml (1.1 and 1.2 modes) | yes |
Go gopkg.in/yaml.v3 | yes |
| Ruby Psych | yes |
js-yaml | yes (merge is in the default schema) |
yaml (Node, eemeli) | opt-in, parse(src, {merge: true}) |
| strict 1.2 / JSON-schema-only parsers | no, << is a literal key |
The failure mode when it is unsupported is quiet: you get a key literally named
<< whose value is a copied mapping, and every field you expected to be inherited
is missing.
Tool support in practice
| Tool | Merge keys |
|---|---|
| Docker Compose | yes |
| GitLab CI | yes, though extends: is preferred and deep-merges |
| Ansible | yes |
| Kubernetes manifests | yes, resolved before the API server sees them |
| CircleCI | yes, the standard way to share job config |
| GitHub Actions | no, aliases are rejected entirely |
GitLab's own extends: keyword exists because it deep-merges where << is
shallow. When both are available, prefer the tool's native mechanism.
Debugging
yq resolves merges and aliases so you can see the flattened result:
yq 'explode(.)' docker-compose.yml yq '.prod' config.yml # merged view of one key python -c "import yaml,sys,json; \ print(json.dumps(yaml.safe_load(open('config.yml')), indent=2, default=str))"
If explode shows a literal << key in the output, the file is being read by
something that does not support merges, and you need to flatten it by hand or
switch parsers.