YAML anchors and aliases let you write a value once and reuse it: &name marks a node as an anchor, and *name later in the same document refers back to it. A merge key, <<: *name, copies an anchored mapping’s keys into another mapping, and the keys you write yourself override the merged ones. Anchors and aliases are part of the YAML specification. Merge keys are not: they come from YAML 1.1’s type repository, so whether << merges depends on the tool or library reading the file. This page covers the syntax, then shows which Go, Rust and C# libraries merge by default and how to edit an anchored file from code.
Anchors and aliases
An anchor names a node, and an alias stands for that same node. The YAML 1.2.2 specification defines both. This file reuses one environment block in two jobs:
jobs: test: env: &shared-env NODE_ENV: test LOG_LEVEL: info lint: env: *shared-env
To a reader, lint.env holds the same two keys as test.env. Rules that catch people out:
- Define before use. An alias refers back to an anchor earlier in the document. GitLab’s docs put it as “you must define the anchor higher in the YAML file than any aliases that reference it” (GitLab CI).
- One document, one file. An anchor belongs to the document it is in, so it can’t be used after a
---that starts a new document. Tools that include files may not share anchors between them either: GitLab says anchors “are only valid in the file they were defined in” when you use itsincludekeyword. - Any node can be anchored. Scalars, sequences and mappings can all be anchored. A plain alias repeats the whole node; it can’t change part of it. That is what merge keys are for.
- An alias is not text substitution. Tools resolve anchors while parsing, before anything else happens. Docker Compose notes that “anchor resolution takes place before variables interpolation, so variables can’t be used to set anchors or aliases” (Compose fragments).
Merge keys: reuse a mapping and override part of it
A << key whose value is an anchored mapping inserts that mapping’s keys into the current one. The merge key type says each key is inserted “unless the key already exists in it”, so keys you write in the mapping win:
x-defaults: &defaults image: nginx:1.27 restart: unless-stopped # shared by every service services: web: <<: *defaults ports: ["8080:80"] worker: <<: *defaults restart: always
Here web gets image: nginx:1.27 and restart: unless-stopped, and worker keeps its own restart: always. The value of << may also be a list, <<: [*base, *logging]. Then the mappings merge in order, and “keys in mapping nodes earlier in the sequence override keys specified in later mapping nodes.” Merge keys only work on mappings; there is no merge for sequences, as Docker’s docs point out.
The prefix x- on x-defaults is a Compose convention: “Compose ignores any fields that start with x-”, so such a key can hold a fragment for reuse (Compose extensions).
Merge keys are not part of YAML 1.2
The merge key page on yaml.org is a YAML 1.1 working draft from 2005. The YAML 1.2.2 specification defines three schemas, Failsafe, JSON and Core, and none of them includes <<. To a strict YAML 1.2 reader it is an ordinary key whose value is the anchored mapping. Tools decide for themselves:
- Docker Compose merges: “Compose follows the rule outlined by YAML merge type” (fragments).
- GitLab CI lists “map merging (
<<)” among the YAML features it supports (YAML optimization). - GitHub Actions documents anchors and aliases for workflows. Its page doesn’t mention merge keys (reusing workflow configurations, read on 26 October 2026).
Libraries differ in the same way, and that matters when your program reads a file a tool also reads.
Which YAML libraries merge by default
Each program below reads the same compose fragment into a typed structure and prints what web ends up with. An empty image means << was read as an ordinary key and nothing was merged.
Go
package main import ( "fmt" "strings" goccy "github.com/goccy/go-yaml" yamlv3 "go.yaml.in/yaml/v3" oneyaml "oneyaml.dev/go" k8syaml "sigs.k8s.io/yaml" ) const src = `x-defaults: &defaults image: nginx:1.27 restart: unless-stopped services: web: <<: *defaults restart: always ` type Compose struct { Services map[string]struct { Image string `yaml:"image" json:"image"` Restart string `yaml:"restart" json:"restart"` } `yaml:"services" json:"services"` } func main() { decoders := []struct { name string unmarshal func([]byte, any) error }{ {"go.yaml.in/yaml/v3", yamlv3.Unmarshal}, {"sigs.k8s.io/yaml", func(b []byte, v any) error { return k8syaml.Unmarshal(b, v) }}, {"goccy/go-yaml", goccy.Unmarshal}, {"goccy + AllowDuplicateMapKey", func(b []byte, v any) error { return goccy.UnmarshalWithOptions(b, v, goccy.AllowDuplicateMapKey()) }}, {"oneyaml.dev/go", func(b []byte, v any) error { return oneyaml.Unmarshal(b, v) }}, {"oneyaml + MergeKeys", func(b []byte, v any) error { return oneyaml.Unmarshal(b, v, oneyaml.MergeKeys()) }}, } for _, d := range decoders { var c Compose if err := d.unmarshal([]byte(src), &c); err != nil { fmt.Printf("%-29s error: %s\n", d.name, strings.SplitN(err.Error(), "\n", 2)[0]) continue } web := c.Services["web"] fmt.Printf("%-29s image=%q restart=%q\n", d.name, web.Image, web.Restart) } }
go.yaml.in/yaml/v3 image="nginx:1.27" restart="always" sigs.k8s.io/yaml image="nginx:1.27" restart="always" goccy/go-yaml error: [7:5] duplicate key "restart" goccy + AllowDuplicateMapKey image="nginx:1.27" restart="always" oneyaml.dev/go image="" restart="always" oneyaml + MergeKeys image="nginx:1.27" restart="always"
yaml.v3 (v3.0.5) and sigs.k8s.io/yaml (v1.6.0) merge by default. goccy/go-yaml v1.19.2 merges, but by default it reports the overriding restart as a duplicate key; its AllowDuplicateMapKey() option accepts the file (pkg.go.dev). oneyaml follows YAML 1.2 and merges only when you pass MergeKeys().
Rust
use oneyaml::Settings; use serde::Deserialize; use std::collections::BTreeMap; const SRC: &str = "\ x-defaults: &defaults image: nginx:1.27 restart: unless-stopped services: web: <<: *defaults restart: always "; #[derive(Deserialize)] struct Compose { services: BTreeMap<String, Service>, } #[derive(Deserialize)] struct Service { image: Option<String>, restart: Option<String>, } fn row(name: &str, read: Result<Compose, String>) { match read { Ok(c) => { let web = &c.services["web"]; println!("{name:<24} image={:?} restart={:?}", web.image.as_deref().unwrap_or(""), web.restart.as_deref().unwrap_or("")); } Err(e) => println!("{name:<24} error: {}", e.lines().next().unwrap_or("")), } } fn main() { row("serde_yaml_ng 0.10.0", serde_yaml_ng::from_str(SRC).map_err(|e| e.to_string())); row("yaml_serde 0.10.7", yaml_serde::from_str(SRC).map_err(|e| e.to_string())); row("serde-saphyr 2.0.0", serde_saphyr::from_str(SRC).map_err(|e| e.to_string())); row("oneyaml 1.2.0", oneyaml::from_str(SRC).map_err(|e| e.to_string())); row("oneyaml + merge_keys", oneyaml::from_str_with(SRC, &Settings::new().merge_keys(true)) .map_err(|e| e.to_string())); }
serde_yaml_ng 0.10.0 image="" restart="always" yaml_serde 0.10.7 image="" restart="always" serde-saphyr 2.0.0 image="nginx:1.27" restart="always" oneyaml 1.2.0 image="" restart="always" oneyaml + merge_keys image="nginx:1.27" restart="always"
serde_yaml_ng 0.10.0 and yaml_serde 0.10.7 don’t merge during a typed read. Both have Value::apply_merge for merging after you load a Value (serde_yaml_ng, yaml_serde). serde-saphyr 2.0.0 merges by default (its merge_keys option defaults to MergeKeyPolicy::Merge, per docs.rs). oneyaml merges with Settings::merge_keys(true), or with the Compat::yaml_1_1() preset, which turns on every YAML 1.1 behavior.
C#
using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; const string Src = """ x-defaults: &defaults image: nginx:1.27 restart: unless-stopped services: web: <<: *defaults restart: always """; var ydn = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); var readers = new (string Name, Func<Compose> Read)[] { ("YamlDotNet 18.1.0", () => ydn.Deserialize<Compose>(Src)), ("YamlDotNet + MergingParser", () => ydn.Deserialize<Compose>(new MergingParser(new Parser(new StringReader(Src))))), ("OneYaml 1.2.1", () => OneYaml.YamlSerializer.Deserialize<Compose>(Src)!), ("OneYaml + MergeKeys", () => OneYaml.YamlSerializer.Deserialize<Compose>(Src, new OneYaml.YamlSerializerOptions { MergeKeys = true })!), }; foreach (var (name, read) in readers) { try { Service web = read().Services["web"]; Console.WriteLine($"{name,-27} image=\"{web.Image}\" restart=\"{web.Restart}\""); } catch (Exception e) { Console.WriteLine($"{name,-27} {e.GetType().Name}: {e.Message.Split('\n')[0]}"); } } class Compose { public Dictionary<string, Service> Services { get; set; } = new(); } class Service { public string? Image { get; set; } public string? Restart { get; set; } }
YamlDotNet 18.1.0 image="" restart="always" YamlDotNet + MergingParser image="nginx:1.27" restart="always" OneYaml 1.2.1 image="" restart="always" OneYaml + MergeKeys image="nginx:1.27" restart="always"
YamlDotNet 18.1.0 merges when you wrap its parser in MergingParser. OneYaml 1.2.1 merges with MergeKeys = true on YamlSerializerOptions; with it off, << is an ordinary key (serializer options). SharpYaml documents merge-key support under its Core and Extended schemas (compatibility notes).
Edit a file that uses anchors and merge keys
A program that loads a compose file into data and writes it back out loses the anchors: every alias comes out as a full copy, and the comments are gone. oneyaml’s Document edits the text instead. Parsed with MergeKeys(), its reads see merged values, and setting a key that only a merge supplies gives that mapping its own key, so the shared anchor stays as it was:
package main import ( "fmt" "log" "os" oneyaml "oneyaml.dev/go" ) func main() { data, err := os.ReadFile("compose.yaml") if err != nil { log.Fatal(err) } doc, err := oneyaml.Parse(data, oneyaml.MergeKeys()) if err != nil { log.Fatal(err) } // Reads see the merged values. var image string if err := doc.Get("/services/web/image", &image); err != nil { log.Fatal(err) } fmt.Println("web image:", image) // Setting a merged key gives web its own key; the anchor is untouched. if err := doc.Set("/services/web/image", "nginx:1.28"); err != nil { log.Fatal(err) } fmt.Print(doc) }
web image: nginx:1.27 x-defaults: &defaults image: nginx:1.27 restart: unless-stopped # shared by every service services: web: <<: *defaults ports: ["8080:80"] image: nginx:1.28 worker: <<: *defaults restart: always
Only web changed. The anchor, the alias and the comment are byte for byte as they were, and worker still gets nginx:1.27. To change every service at once, set /x-defaults/image instead. An edit that would change a key only a merge source has is refused with ErrAliasWrite rather than silently changing the anchor (editing). The same edits in each language are in the cookbook: Go, Rust and C#. For edits in general, see edit YAML without losing comments.
Aliases and untrusted input
An alias is cheap to write and can be expensive to expand. The “billion laughs” file nests ten aliases to the line above on each of nine lines: a few hundred bytes that expand to a billion copies of one string. If your program reads YAML from users or the network, use a library that bounds alias expansion. oneyaml bounds it by default in all three languages and has a stricter preset for untrusted input (Go, Rust, C#). A Document keeps aliases as written in the text, as the edit above shows.
Booleans and numbers have their own version trap; see the YAML Norway problem. All three oneyaml libraries are on the oneyaml home page. Versions on this page are the latest published on 26 October 2026.