YAML Anchors, Aliases and Merge Keys Explained (With Real Config Examples)
Anchors and aliases are YAML's only way of saying "the same thing again", and the merge key extends that to "the same mapping, with a few changes". They appear in nearly every docker-compose file, GitLab CI pipeline and Helm values file of any size, and they are also behind some of the most confusing parser behaviour in the format. This guide explains the three constructs precisely, shows what a parser actually produces from them, and covers the errors and design traps that come up in real configuration.
In short
An anchor (&name) labels a YAML node so an alias (*name) can reuse it elsewhere in the same document; the merge key (<<:) copies a mapping's keys into another one, with keys already written in the target always winning. Merging is shallow and never spans documents or files, and YAML 1.2 parsers such as the JavaScript yaml package treat << as an ordinary key unless merge mode is enabled.
Anchors and aliases: one node, many references
An anchor is a label attached to a node with an ampersand: &name. An alias, written *name, refers back to that node. The anchored node can be a scalar, a sequence or a mapping, and the alias can appear anywhere a node is allowed: as a value, inside a list, or nested several levels below. Anchor names are case-sensitive, may contain letters, digits, hyphens and underscores, and must be defined before they are used, because the parser resolves aliases in a single left-to-right pass over the document.
Conceptually an alias is a reference to the same node, not a copy. Once the document is loaded into a language that has no reference syntax (JSON, or a plain JavaScript object) that distinction disappears and every alias becomes a full copy of the anchored value. The YAML below anchors a scalar and a mapping and reuses both; the resolved data contains the region string twice and the resources block twice, with no trace of the anchor names.
region: ®ion ap-south-1
resources: &small
limits: { cpu: 500m, memory: 512Mi }
requests: { cpu: 100m, memory: 128Mi }
api:
region: *region
resources: *small
worker:
region: *region
resources: *smallMerge keys: what <<: does and which value wins
The merge key << takes a mapping, or a list of mappings, as its value and copies that mapping's keys into the current one. Keys written directly in the current mapping always win, whether they appear above or below the << line. When the value is a list of aliases, the mappings are applied in order and the first one to define a key wins: earlier entries take precedence over later ones, which is the opposite of what people used to Object.assign or dictionary updates expect.
Merging is shallow. If the base and the child both define a logging mapping, the child's logging replaces the base's entirely; nothing inside is combined. A null in the child overrides too; there is no way to unset an inherited key. In the example, prod resolves to { timeout: 60, retries: 3 } even though timeout is written before the merge, and c resolves to { x: from-a, shared: from-a, y: from-b, z: local }: x and shared come from a because a is first in the list, y exists only in b, and z is local.
| Merge form | Precedence | Example result |
|---|---|---|
| Local key vs. merged key | Local key always wins | prod.timeout: 60 (local) over defaults.timeout: 30 |
| <<: [*a, *b] (list of aliases) | Earlier alias wins on conflicting keys | c.x = "from-a" (a is listed before b) |
| Nested mapping in base and child | Child's mapping replaces the base's entirely (shallow merge) | child.logging fully replaces base.logging |
defaults: &defaults
timeout: 30
retries: 3
prod:
timeout: 60 # local key wins, even when written above the merge
<<: *defaults
a: &a { x: from-a, shared: from-a }
b: &b { x: from-b, y: from-b, shared: from-b }
c:
<<: [*a, *b] # a is applied first, so a's keys win over b's
z: localWhere you meet them: docker-compose, GitLab CI and Helm
Compose files allow top-level keys beginning with x- (extension fields) precisely so that there is somewhere to put anchored fragments that are not themselves services. The standard pattern is one x-common block merged into every service, with per-service keys overriding the shared ones. Compose ignores x- keys, so the base never turns into a service by accident. Note the quoted "no" in the worker below: in the YAML 1.1 schema that many tools still use, an unquoted no is the boolean false, which is why the Compose documentation insists on quoting it.
GitLab CI has the same idea with hidden jobs: any top-level key that starts with a dot is skipped by the pipeline runner, which makes .node-job: &node-job the conventional place to anchor shared image, cache and before_script settings, with each real job pulling them in through <<: *node-job. GitLab also offers the extends: keyword, which merges recursively and works across files pulled in with include:. Anchors do neither, because they are resolved by the YAML parser inside a single file before GitLab ever sees the data.
Helm values files are plain YAML, so anchors work inside one file. Helm expands them while reading that file, and when several sources are combined (values.yaml, a second -f file, --set flags) the merge happens on the expanded data, so an anchor defined in one file cannot be referenced from another. Kubernetes manifests applied with kubectl resolve anchors the same way, but a multi-resource manifest separated by --- is a multi-document stream, and anchors do not cross document boundaries (more on that below).
x-common: &common
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
services:
api:
<<: *common
image: ghcr.io/acme/api:1.4
worker:
<<: *common
image: ghcr.io/acme/worker:1.4
restart: "no" # overrides the merged valueHow a YAML 1.2 parser treats the merge key
Merge keys were never part of the YAML specification proper. They come from a 2005 "Merge Key Language-Independent Type" proposal written for YAML 1.1, and YAML 1.2, the current specification since 2009, does not mention them at all. Parsers therefore differ. PyYAML, go-yaml (which Kubernetes and Helm use), Ruby's Psych and libyaml all implement 1.1 and merge by default. A strict 1.2 parser is entitled to treat << as an ordinary key whose value happens to be a mapping.
The yaml package for JavaScript (version 2, the one used in this article) does exactly that. With default options it parses <<: *base into a literal property named << holding a copy of the base mapping, and nothing is merged. Pass merge: true, or select version: "1.1", and it applies the merge semantics described above. The same split applies to booleans: the 1.2 core schema leaves yes and no as strings where 1.1 turned them into true and false. The snippet below opts in; the JSON in the next section is its exact output.
When a converter or linter shows a << key in its output, the tool is not broken; it is following YAML 1.2 to the letter — the YAML Validator will confirm whether merging was actually applied to a given file. Enable merging explicitly if you control the code; if the file is destined for a 1.1-era tool such as Compose or GitLab, the merge will happen there regardless.
import { parse } from "yaml";
const source = `
x-common: &common
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
services:
api:
<<: *common
image: ghcr.io/acme/api:1.4
worker:
<<: *common
image: ghcr.io/acme/worker:1.4
restart: "no"
`;
// YAML 1.2 (the default) treats "<<" as an ordinary key; opt in to merging.
const config = parse(source, { merge: true });
console.log(JSON.stringify(config.services, null, 2));The resolved output
Two things stand out in the result. The worker's restart is "no" because the local key overrode the merged one, while its logging block is a full copy of the shared one. And every service now carries its own copy of that logging block: two services, two copies; forty services, forty copies. The duplication is invisible in the YAML and real in the parsed data, which matters when the data is later re-serialized, diffed or stored.
The x-common key itself survives in the top-level result; it is omitted here only because the snippet prints config.services. Tools that understand extension fields ignore it, a generic YAML to JSON conversion keeps it, and a schema that forbids unknown keys will reject it.
{
"api": {
"restart": "unless-stopped",
"logging": {
"driver": "json-file",
"options": { "max-size": "10m" }
},
"image": "ghcr.io/acme/api:1.4"
},
"worker": {
"restart": "no",
"logging": {
"driver": "json-file",
"options": { "max-size": "10m" }
},
"image": "ghcr.io/acme/worker:1.4"
}
}Errors you will actually see
Alias before anchor. Because aliases are resolved in one pass, using *base above the line that defines &base fails. The yaml package reports "Unresolved alias (the anchor must be set before the alias): base", or, with merging enabled, the terser "Merge sources must be maps or map aliases", since the alias could not be resolved to a mapping; PyYAML says "found undefined alias 'base'". Move the anchored block up, which is why shared fragments conventionally sit at the top of the file.
Anchors across documents. Anchors are scoped to a single document. In a multi-document stream separated by --- the second document cannot see anchors from the first, and the error is the same unresolved-alias message, which is confusing because the anchor is plainly there a few lines up. Kubernetes manifests with several resources in one file are the classic case: an anchor defined in the Deployment is invisible to the Service below it.
Merging a sequence. The merge key only accepts mappings. <<: *list where the anchor points at a sequence fails with "Merge sources must be maps or map aliases" in the yaml package and "expected a mapping for merging, but found scalar" in PyYAML, which walks into the sequence and rejects its items. There is no anchor-based way to concatenate lists: writing - *common inside a list inserts the whole aliased list as one nested element rather than splicing its items in, so a pipeline of [*common, deploy] becomes [[lint, test], deploy].
Duplicate anchor names are legal and quietly surprising: a later definition with the same name replaces the earlier one for any alias that follows, while aliases that were resolved before the redefinition keep the old value. Finally, parsers cap alias expansion to defend against "billion laughs" documents, where nested aliases expand exponentially; the yaml package's maxAliasCount defaults to 100 and refuses larger documents with "Excessive alias count indicates a resource exhaustion attack"; raise the limit for legitimately alias-heavy files.
# Fails: "Unresolved alias (the anchor must be set before the alias): base"
web:
<<: *base
base: &base
image: node:22When explicit duplication is better, and how anchors disappear
Anchors save typing but cost readability. A reader has to scroll to the anchor to know what <<: *common brings in, and the override rules above are subtle enough that reviewers regularly misread which value wins. Prefer plain duplication when the shared block is small, when the file is edited by people who do not know YAML well, or when the consuming tool has its own inheritance mechanism (GitLab's extends, Compose's extends and include, Kustomize patches, Helm's value layering). Those mechanisms are validated and documented by the tool; an anchor is just text.
Anchors are also fragile because they exist only in the text. Any tool that parses a file and writes it back (a formatter, an in-place edit with yq, a round trip through JSON, a controller that stores the object and re-serializes it) loses them: the output contains the expanded copies. The yaml package's stringify invents anchors of its own (&a1, &a2) only when the same JavaScript object appears more than once in the data, which never happens after a merge: the child holds copies, not references. To preserve the original anchors through a rewrite, work on the document tree with parseDocument and toString rather than on parsed values.
import { parse, parseDocument, stringify } from "yaml";
const src = "base: &base\n a: 1\nchild:\n <<: *base\n b: 2\n";
// Value round trip: the merge is expanded and the anchor is gone.
console.log(stringify(parse(src, { merge: true })));
// base:
// a: 1
// child:
// a: 1
// b: 2
// Document round trip: the tree still holds the anchor and the merge key.
console.log(parseDocument(src).toString());
// base: &base
// a: 1
// child:
// <<: *base
// b: 2Sources
Frequently asked questions
Can I use an anchor defined in one file from another file?
No. Anchors are resolved by the YAML parser within a single document, before any tool sees the data. Compose's include and extends, GitLab's include and extends, and Helm's multiple -f files all combine already-parsed data, so a shared fragment that must span files has to use the tool's own inheritance feature rather than an anchor.
Why does my YAML to JSON conversion output a key literally named <<?
The converter uses a YAML 1.2 parser with merging turned off, which is the default for the JavaScript yaml package. Under 1.2 the merge key is not special, so << is kept as a normal key whose value is the aliased mapping. Enable the parser's merge option, or select the 1.1 schema, and the keys are folded into the parent mapping as expected.
Does the merge key deep-merge nested mappings?
No. It copies top-level keys only, and a key present in the child replaces the inherited value wholesale, so a child that sets logging: { driver: syslog } drops the inherited options block entirely. If you need recursive merging, the consuming tool has to provide it: GitLab's extends and Helm's value layering both merge recursively; plain YAML does not.
Are anchors the same as variables?
They are not. An alias reuses a whole node; it cannot be substituted inside a string, so image: node:*version does not work. String interpolation such as ${CI_COMMIT_REF_SLUG} or ${TAG} in Compose is handled by the tool after parsing and has nothing to do with YAML. Use anchors for structural reuse and the tool's variable syntax for values that vary.
Paste a file that uses anchors into the YAML Validator to see exactly which alias fails to resolve, with the line and column.
Check your YAMLKeep working
Related tools
YAML Validator
Full YAML 1.2 validation with the exact line and column of every error — catch the indent bug before your pipeline does.
YAML Formatter
Normalize any YAML to clean, consistent style — uniform indentation, canonical quoting, validation built in.
YAML to JSON
Full YAML 1.2 parsing — anchors resolved, types preserved, errors pinpointed — straight into clean JSON.
YAML Parser
See exactly what your tooling sees — full YAML 1.2 parsing with typed scalars, resolved anchors, and precise errors, shown as the JSON value it becomes.
YAML Viewer
Explore any YAML document as an interactive tree — mappings, lists, and counts at a glance, anchors resolved.
YAML Diff
Two YAML documents side by side with syntax highlighting, so an indentation change reads as a structural change rather than as moved whitespace.