Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 5 – YAML 1.2 and its traps

YAML anchors, aliases and merge keys

Reuse YAML nodes with anchors and aliases, see when loaded data is shared, apply merge keys by their rules and keep alias expansion bounded for files you load.

  • Intermediate
  • 15 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Reuse a node with an anchor and aliases, and predict when the loaded data is shared
  • Apply merge keys by hand, with one mapping and with a list of mappings
  • Check whether a tool applies merge keys, knowing that they are a YAML 1.1 type
  • Limit alias expansion when loading YAML that you did not write

Before you start

What you’ll learn · Before you start

What you will learn

  • Reuse a node with an anchor and aliases, and predict when the loaded data is shared
  • Apply merge keys by hand, with one mapping and with a list of mappings
  • Check whether a tool applies merge keys, knowing that they are a YAML 1.1 type
  • Limit alias expansion when loading YAML that you did not write

Before you start

On this page

A YAML file of 312 bytes holds eight levels: a list of four strings, then seven lists that each repeat the level before them four times through aliases. The last level expands to 65,536 strings, 502,441 bytes once it is written out as JSON. The feature behind it, writing a node once and reusing it, is what keeps CI pipelines and Compose files short, and it hides two more surprises: reused data is shared in memory, and merge keys follow rules that not every parser applies.

You will write anchors and aliases and see what a loader builds from them, change one job and watch two change, apply merge keys by the rules of the YAML 1.1 merge type, check which tools apply them at all, and put a limit on alias expansion before you load a file someone else wrote.

An anchor names a node, and an alias reuses it

An anchor: A name given to a YAML node with &, such as &defaults, so that the node can be reused later in the same document by an alias., &name, marks a node, and an alias: A YAML node written *name that stands for the most recent node anchored with that name earlier in the document. A loader shares that node rather than copying it., *name, stands for that node again later in the document. The YAML 1.2.2 specification ties each alias to “the most recent preceding node having the same anchor”, so an alias must come after its anchor, in the same document. Here are the jobs of the bookshop’s CI pipeline, whose defaults are written once:

ci.yaml: defaults written once, used twice YAML · aliases/ci.yaml
# Jobs of the bookshop's CI pipeline: the defaults are written once
defaults: &defaults
  image: ruby:4.0
  retries: 2
  timeout_minutes: 10
jobs:
  test: *defaults
  lint: *defaults

Step through the file as a parser and then a loader see it, and count the mappings at each step:

Interactive · step by step One anchored mapping, reached by three keys Step through ci.yaml as a parser and a loader see it. Watch how many mappings exist at each step.
One anchored mapping, reached by three keys, step 6 of 6. Showing defaults: &defaults, test: *defaults, lint: *defaults, One mapping (copied: one per job).defaults: &defaultsthe anchor names the nodetest: *defaultsan aliaslint: *defaultsanother aliasOne mappingcopied: one per job

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The anchor. The parser reads the mapping under defaults and records its anchor, &defaults. The anchor names the node; it is neither a key nor part of the data.
  2. An alias, not a copy. Under jobs, test: *defaults is an alias. It does not copy the mapping: it points at the node that the most recent &defaults named.
  3. A second alias. lint: *defaults points at the same node. The document now holds one mapping and three ways to reach it.
  4. One object. With aliases allowed, Psych builds the mapping once. aliases.rb printed that test and lint are equal and are the same object.
  5. One change, three places. Setting test's retries to 5 changes that one object, so lint and defaults read 5 as well. Nothing in the file said they were linked except the aliases.
  6. Copies separate them. A deep copy for each job gives test and lint objects of their own. After the same change, lint still read 2.

The program loads the file with Psych, the YAML parser in Ruby’s standard library, changes one job, copies the jobs, tries an alias with no anchor, and writes the data back out:

Anchors and aliases in Psych: refused, shared, copied, written Ruby · aliases/aliases.rb
# Anchors and aliases in Psych: refused by default, then shared, then copied.
require "yaml"

text = File.read("ci.yaml")
begin
  YAML.safe_load(text)
rescue Psych::AliasesNotEnabled => e
  puts "Default safe_load: #{e.class}"
end

ci = YAML.safe_load(text, aliases: true)
test, lint = ci["jobs"].values_at("test", "lint")
puts "test equals lint: #{test == lint}; the same object: #{test.equal?(lint)}"

test["retries"] = 5
puts "After test.retries = 5: lint #{lint['retries']}, defaults #{ci['defaults']['retries']}"

# The fix: give every job its own deep copy before changing anything.
ci = YAML.safe_load(text, aliases: true)
ci["jobs"].transform_values! { |job| Marshal.load(Marshal.dump(job)) }
ci["jobs"]["test"]["retries"] = 5
puts "With copies: test #{ci['jobs']['test']['retries']}, lint #{ci['jobs']['lint']['retries']}"

# An alias needs an anchor earlier in the same document.
begin
  YAML.safe_load("jobs:\n  deploy: *release\n", aliases: true)
rescue Psych::AnchorNotDefined => e
  puts "Alias without an anchor: #{e.message}"
end

# What Psych writes back for data that is shared.
shared = YAML.safe_load(text, aliases: true)
puts
puts YAML.dump(shared)

Output

Default safe_load: Psych::AliasesNotEnabled
test equals lint: true; the same object: true
After test.retries = 5: lint 5, defaults 5
With copies: test 5, lint 2
Alias without an anchor: An alias referenced an unknown anchor: release

---
defaults: &1
  image: ruby:4.0
  retries: 2
  timeout_minutes: 10
jobs:
  test: *1
  lint: *1

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby aliases.rb

The first line is Psych’s default: safe_load refuses every alias with Psych::AliasesNotEnabled until the program passes aliases: true, a deliberate choice that the last section of this lesson explains. With aliases allowed, test and lint are not only equal, they are the same object, exactly as the steps showed. An alias with no anchor before it is an error, Psych::AnchorNotDefined, as the specification requires.

GitHub’s documentation for Actions says that anchors and aliases “reduce repetition in your workflows”, and Docker’s Compose reference calls them a way to “create reusable blocks”. An alias always stands for a whole node; changing part of a reused node is the job of the merge keys below.

Checkpoint

Check: Anchors and aliases

  1. Question 1 of 2 Where must the anchor &defaults be for *defaults to work?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Earlier in the same document

    Anchors and aliases work inside one document, in order: anchor first, aliases after. Psych reported the alias *release without an anchor as Psych::AnchorNotDefined.

  2. Question 2 of 2 Psych's safe_load, with no options, reads ci.yaml. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It raises Psych::AliasesNotEnabled

    Refusing aliases by default is a safety choice: a document of a few hundred bytes can expand to a great deal of data, as the last section of the lesson measures.

Aliases share one object, so one change shows up everywhere

An alias is not a copy. A loader builds the anchored node once and puts the same object wherever an alias stands, and PyYAML’s documentation says the same of Python: anchors and aliases are how a document refers “to the same object from different parts”. Here is the data that a loader builds for test: *defaults and lint: *defaults, written as Python so you can step through it:

Interactive · line by line Two keys, one dict, and the copy that separates them Watch the boxes for jobs. Both keys point at one dict until line 10 gives each job a dict of its own.

Program · aliases/shared.py

# A loader's result for test: *d and
# lint: *d is one dict with two names.
defaults = {"retries": 2}
jobs = {"test": defaults, "lint": defaults}
jobs["test"]["retries"] = 5
print(jobs["lint"]["retries"])

# The fix: one copy for each job.
defaults = {"retries": 2}
jobs = {"test": dict(defaults), "lint": dict(defaults)}
jobs["test"]["retries"] = 5
print(jobs["lint"]["retries"])

Variables

After line 12: defaults is {'retries': 2}; jobs is {'test': {'retries': 5}, 'lint': {'retries': 2}}.defaults'retries':2jobs'test':{'retries': 5}'lint':{'retries': 2}
  • changed in this step

Printed so far

5
2

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 9 steps are listed below.

Line run 12
Next line end
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 3.
  2. Line 3. defaults = {"retries": 2} makes defaults a dict with 1 key.
  3. Line 4. jobs = {"test": defaults, "lint": defaults} makes jobs a dict with 2 keys. Key point Both keys hold the same dict, as both aliases of ci.yaml point at one node.
  4. Line 5. jobs["test"]["retries"] = 5 changes jobs['test'] from {'retries': 2} to {'retries': 5} and changes jobs['lint'] from {'retries': 2} to {'retries': 5}. The bug Writing through test changes the one dict that lint holds too.
  5. Line 6. print(jobs["lint"]["retries"]) prints 5. lint reads 5, although no line changed it by name.
  6. Line 9. defaults = {"retries": 2} sets defaults to {'retries': 2} (it was {'retries': 5}).
  7. Line 10. jobs = {"test": dict(defaults), "lint": dict(defaults)} sets jobs to {'test': {'retries': 2}, 'lint': {'retries': 2}} (it was {'test': {'retries': 5}, 'lint': {'retries': 5}}). The fix dict(defaults) makes a new dict for each job, so the jobs no longer share one.
  8. Line 11. jobs["test"]["retries"] = 5 changes jobs['test'] from {'retries': 2} to {'retries': 5}.
  9. Line 12. print(jobs["lint"]["retries"]) prints 2. That was the last line, so the program ends. This time lint still reads 2.

Line 5 changed test, and line 6 found the change in lint, because both names lead to one dict. The second half gives each job its own copy first, and lint keeps 2. The Ruby run above showed the same in Psych: after test["retries"] = 5, both lint and defaults read 5, and with a deep copy per job, made with Marshal, lint kept 2.

The last lines of the Ruby output show what happens on the way back out. YAML.dump noticed the shared object and wrote it with an anchor of its own, &1 where the file had &defaults, and *1 for both jobs: the sharing survived, the names did not. A program that loads, changes and dumps a file with aliases rewrites its anchors.

Editing loaded data that came from aliases

Wrong: load a pipeline file, raise the retries of one job, and save it. Every job that shares the node changes too. Right: copy each job before changing it, or change the anchored node on purpose when every job should change, and check the written file rather than trusting the edit.

Checkpoint

Check: Shared objects from aliases

  1. Question 1 of 2 With aliases allowed, the program set test["retries"] = 5. What did defaults["retries"] read afterwards?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 5

    test, lint and defaults are one object, so the change shows through all three: aliases.rb printed lint 5 and defaults 5. With a deep copy per job, lint kept 2.

  2. Question 2 of 2 What did YAML.dump write for the loaded ci.yaml?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The mapping once with the anchor &1, and *1 for both jobs

    A load and a dump keep the sharing but not the names of anchors, so a program that edits and saves a file with aliases changes its text even where it changed no value.

Merge keys copy keys, and the mapping’s own keys win

An alias reuses a whole node. To reuse most of a mapping and change one key, YAML 1.1 has the merge key: The YAML 1.1 key <<, whose value is a mapping or a list of mappings whose keys are copied into the current mapping, except keys it already has. YAML 1.2 removed it., <<. The type page says that the keys of the merged mappings are inserted into the current mapping “unless the key already exists in it”, and that with a list of mappings “keys in mapping nodes earlier in the sequence override” the later ones. These four jobs use it:

jobs.yaml: four jobs built with merge keys YAML · merge/jobs.yaml
# Merge keys: copy the keys of other mappings into this one
base: &base
  image: ruby:4.0
  retries: 2
  env: {CI: "true"}
slow: &slow
  timeout_minutes: 30
  retries: 5
test:
  <<: *base
  retries: 3              # a key of the mapping itself wins
deploy:
  <<: [*base, *slow]      # the earlier mapping wins: retries from base
lint:
  <<: *base
  env: {LINT: "on"}       # shallow: env is replaced, not combined
late:
  retries: 9
  <<: *base               # the merge key after the key

Step through three of them, one merged mapping at a time:

Interactive · step by step Which keys a merge key brings in, and which it skips Step through three jobs of jobs.yaml. Each step adds the keys of one mapping to the job, unless the job already has them.
Which keys a merge key brings in, and which it skips, step 6 of 6. Showing &base, &slow, The job's own keys (late: retries 9, then <<), The merged job (late in Psych: retries 2).&baseimage, retries 2, env&slowtimeout 30, retries 5The job's own keyslate: retries 9, then <<The merged joblate in Psych: retries 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. deploy: a list of two. deploy has no keys of its own, only <<: [*base, *slow]. The merge type takes the mappings of the list in order.
  2. base comes first. All keys of base go in: image, retries 2 and env.
  3. slow adds only what is new. slow brings timeout_minutes, which is new. Its retries 5 is skipped: deploy already has retries from base, and earlier mappings in the list win.
  4. test: its own key wins. test has its own retries: 3 and <<: *base. base brings image and env; its retries 2 is skipped because the mapping already has the key.
  5. late: the order of lines. late writes retries: 9 before <<: *base. By the merge type the result is still 9, but Psych 5.3.1 let base overwrite it with 2.
  6. Put << first. Written first in its mapping, a merge key gives the same result in Psych and by the type's rule, so put << before the keys that override it.

The program prints what Psych 5.3.1 built for each job:

What Psych builds from the merge keys of jobs.yaml Ruby · merge/merge.rb
# What Psych 5.3.1 builds from the merge keys of jobs.yaml.
require "yaml"

jobs = YAML.safe_load(File.read("jobs.yaml"), aliases: true)
%w[test deploy lint late].each do |name|
  puts "#{name.ljust(7)} #{jobs[name].inspect}"
end

# The merge type of YAML 1.1 keeps a key that the mapping already has,
# wherever the merge key stands. Here is what Psych did with late's retries:
puts
puts "late retries: #{jobs['late']['retries']} (the mapping says 9)"

Output

test    {"image" => "ruby:4.0", "retries" => 3, "env" => {"CI" => "true"}}
deploy  {"timeout_minutes" => 30, "retries" => 2, "image" => "ruby:4.0", "env" => {"CI" => "true"}}
lint    {"image" => "ruby:4.0", "retries" => 2, "env" => {"LINT" => "on"}}
late    {"retries" => 2, "image" => "ruby:4.0", "env" => {"CI" => "true"}}

late retries: 2 (the mapping says 9)

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby merge.rb

test kept its own retries: 3 and took image and env from base. deploy merged two mappings and took retries: 2 from base, the earlier one, and only timeout_minutes from slow. lint shows that merging is shallow: its own env replaced the merged one whole, so the CI variable is gone rather than combined with LINT.

late is where Psych and the type page part. By the rule, retries: 9 belongs to the mapping, so it should win whatever its position. Psych applied the merge in the order of the lines and let base overwrite it with 2. Writing << first in its mapping, as Docker’s Compose reference does in its examples, gives the same result in Psych and by the rule.

Key idea

A merge key adds only the keys a mapping does not have, takes them from the earlier mapping of a list first, and replaces nested mappings whole; put << first, so every parser that applies it agrees.

Checkpoint

Check: The rules of merge keys

  1. Question 1 of 2 deploy has <<: [*base, *slow]; base has retries 2 and slow has retries 5. Which retries does deploy get?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 2

    Mappings earlier in the list win, so retries comes from base. slow only adds the key deploy does not have yet, timeout_minutes, as merge.rb printed.

  2. Question 2 of 2 lint has <<: *base and its own env: {LINT: "on"}, and base has env: {CI: "true"}. What is lint's env?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only {LINT: "on"}

    The merge type works one level deep. To combine nested settings, write them out, or merge them in the program.

Merge keys are YAML 1.1, so check every tool that reads the file

The merge key was never part of YAML 1.2. The specification changes page says that “the merge << and value = special mapping keys have been removed”, so a parser that follows only the core schema reads << as an ordinary key whose value is a mapping. What a given tool does is in its documentation, or in a probe:

Reader What happens to <<
Psych 5.3.1 merged, as the run above showed, in the order of the lines
yaml for JavaScript a merge option, false by default for YAML 1.2 and true for YAML 1.1
Docker Compose merged: Compose “follows the rule outlined by YAML merge type”, for mappings only
GitHub Actions its documentation describes anchors and aliases, and says nothing of <<

When one reader of a file merges and another does not, they see different data: the second one finds a key called << and no image in the job. For files that several tools read, either check that every one of them merges, or avoid << and repeat the keys, or let the program apply merge keys itself after loading, which is the exercise of this lesson. In workflow files, use anchors and aliases only, the features the documentation describes.

Probe for merge support

Load a: &a {x: 1} followed by b: {<<: *a} with the tool, and print b. If it comes back with x: 1, the tool merges; if it holds a key <<, it does not.

Checkpoint

Check: Which tools apply merge keys

  1. Question 1 of 2 Which version of YAML removed the merge key?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: YAML 1.2

    Merge keys belong to the YAML 1.1 type library. A parser that follows only the YAML 1.2 core schema reads << as an ordinary key.

  2. Question 2 of 2 A file uses <<: *base. Psych reads it, and so does a loader with merging off. What does the second loader see in the job?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A key named << holding the base mapping, and none of base's keys at the top of the job

    Two readers of one file can see two different jobs. Check that every tool merges, avoid <<, or apply merge keys in the program after loading.

Aliases can multiply data, so cap the expansion

Every alias in memory is a pointer, but whatever turns the data into text, JSON or a copy follows each one. A document in which every level repeats the level before it several times grows like a power. This one has eight levels of four:

nested.yaml: eight levels, each four aliases of the last YAML · expansion/nested.yaml
# Each level holds four aliases of the level before it, eight levels in all
l1: &l1 [book, book, book, book]
l2: &l2 [*l1, *l1, *l1, *l1]
l3: &l3 [*l2, *l2, *l2, *l2]
l4: &l4 [*l3, *l3, *l3, *l3]
l5: &l5 [*l4, *l4, *l4, *l4]
l6: &l6 [*l5, *l5, *l5, *l5]
l7: &l7 [*l6, *l6, *l6, *l6]
l8: &l8 [*l7, *l7, *l7, *l7]

The program loads it, measures each level as JSON, and then loads it again through a guard that counts the nodes the document expands to on the parse tree, where every alias is still a single node, and refuses it above a limit:

How far aliases multiply data, and a guard that refuses it Ruby · expansion/expansion.rb
# How far aliases multiply data, and a limit checked on the parse tree before loading.
require "yaml"
require "json"

text = File.read("nested.yaml")
puts "nested.yaml: #{text.bytesize} bytes"

# Loading builds each level once; the aliases share it. Writing it out does not.
data = YAML.safe_load(text, aliases: true)
puts format("%-6s %8s %11s", "level", "strings", "JSON bytes")
(1..8).each do |n|
  level = data["l#{n}"]
  puts format("%-6s %8d %11d", "l#{n}", level.flatten.size, JSON.generate(level).bytesize)
end

# The defence: count the nodes the document expands to on the parse tree,
# where every alias is still one node, and refuse it before any value is built.
def expanded(node, anchors, memo = {}.compare_by_identity)
  return memo[node] if memo.key?(node)

  size = case node
         when Psych::Nodes::Alias then expanded(anchors.fetch(node.anchor), anchors, memo)
         when Psych::Nodes::Scalar then 1
         else 1 + node.children.sum { |child| expanded(child, anchors, memo) }
         end
  anchors[node.anchor] = node if !node.is_a?(Psych::Nodes::Alias) && node.anchor
  memo[node] = size
end

def guarded_load(text, limit:)
  size = expanded(YAML.parse(text).root, {})
  raise ArgumentError, "expands to #{size} nodes, more than #{limit}" if size > limit

  YAML.safe_load(text, aliases: true)
end

puts
[["the first three levels", text.lines.first(4).join], ["all eight levels", text]].each do |what, yaml|
  loaded = guarded_load(yaml, limit: 10_000)
  puts "#{what}: loaded #{loaded.size} keys"
rescue ArgumentError => e
  puts "#{what}: refused, #{e.message}"
end

Output

nested.yaml: 312 bytes
level   strings  JSON bytes
l1            4          29
l2           16         121
l3           64         489
l4          256        1961
l5         1024        7849
l6         4096       31401
l7        16384      125609
l8        65536      502441

the first three levels: loaded 3 keys
all eight levels: refused, expands to 116513 nodes, more than 10000

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby expansion.rb

The 312 bytes loaded quickly, because Psych built each level once and shared it. Written out, they multiply by four per level: 4 strings at l1, 65,536 at l8, and 502,441 bytes of JSON for the last level alone, about 1,600 times the size of the file. With more levels or wider lists the same few lines can exhaust a program’s memory. The alias expansion: The growth of YAML data when its aliases are followed and copied, as when it is written out as JSON: each level of aliases multiplies the size, so a short file can become very large. count of the guard is computed without building anything: 116,513 nodes for the whole document, so with a limit of 10,000 the guard loaded the first three levels and refused all eight.

Parsers ship such limits. Psych refuses every alias unless the program asks for them with aliases: true. The yaml package for JavaScript has maxAliasCount, 100 by default, to “prevent exponential entity expansion attacks by limiting data aliasing”.

Keep the limits on for YAML you did not write

For files that come from users or other systems, load with aliases off, or on with a limit on the expanded size, as guarded_load does, and cap the file size before parsing. Turn aliases on without a limit only for files you control.

Checkpoint

Check: Alias expansion and its limits

  1. Question 1 of 2 nested.yaml's last level, l8, expands to 65,536 strings. How many would a ninth level, l9: [*l8, *l8, *l8, *l8], expand to?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 262144 strings

    Each level holds four aliases of the one before, so it multiplies the count by four: 65,536 × 4 = 262,144. One more short line of YAML, four times the data.

  2. Question 2 of 2 Why does guarded_load count nodes on the parse tree instead of loading the document and measuring the result?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: On the parse tree every alias is one node, so counting is cheap, and a refused document is never expanded at all

    Check limits before doing the expensive work: guarded_load counted 116,513 nodes and refused the file with a limit of 10,000, without building them.

Practise: apply merge keys after loading

A loader that follows YAML 1.2 returns << as an ordinary key, so a program that wants merges must apply them itself. The exercise asks you to do it by the rules of the merge type, own keys first, earlier mappings next, shallow, at any depth, with the four jobs of this lesson among its sample tests:

Exercise · Medium · Python

Apply YAML merge keys by the rules of the merge type

A YAML 1.2 loader with no merge support returns << as an ordinary key, so the program has to apply the merge itself. Write apply_merge_keys(value) in merge.py. It takes loaded data (dictionaries, lists and plain values) and returns new data in which every "<<" key is applied by the YAML 1.1 merge type, the rule of yaml.org/type/merge.html:

  1. The value of "<<" is a mapping, or a list of mappings. Anything else raises ValueError.
  2. Every key of the merged mappings is added to the mapping that holds "<<", unless the mapping already has that key, wherever "<<" stands among its keys.
  3. With a list, the mappings are merged in order, so a key from an earlier mapping wins over the same key in a later one.
  4. Merging is shallow: a nested mapping is taken as a whole, never combined with another.
  5. Merge keys are applied in mappings at any depth, inside lists too, and "<<" itself is not part of the result.
  6. The input is left unchanged.

The sample tests use the jobs of the lesson: test, deploy, lint and late.

Starter code · merge.py

def apply_merge_keys(value):
    """value with every "<<" merge key applied, by the YAML 1.1 merge type, in mappings at any depth."""
    return value
The sample tests · test_merge.py
from merge import apply_merge_keys

BASE = {"image": "ruby:4.0", "retries": 2, "env": {"CI": "true"}}
SLOW = {"timeout_minutes": 30, "retries": 5}


def test_one_mapping_and_an_own_key():
    """copies the keys of one mapping, and keeps the mapping's own key"""
    assert apply_merge_keys({"<<": BASE, "retries": 3}) == {"image": "ruby:4.0", "retries": 3, "env": {"CI": "true"}}


def test_a_list_of_mappings():
    """lets the earlier mapping of the list win"""
    assert apply_merge_keys({"<<": [BASE, SLOW]}) == {"image": "ruby:4.0", "retries": 2, "env": {"CI": "true"}, "timeout_minutes": 30}
    assert apply_merge_keys({"<<": [SLOW, BASE]})["retries"] == 5


def test_merging_is_shallow():
    """replaces a nested mapping instead of combining it"""
    assert apply_merge_keys({"<<": BASE, "env": {"LINT": "on"}})["env"] == {"LINT": "on"}


def test_the_order_of_lines_does_not_matter():
    """keeps the mapping's own key even when it was written before the merge key"""
    late = {"retries": 9, "<<": BASE}
    assert apply_merge_keys(late)["retries"] == 9


def test_mappings_at_any_depth():
    """applies merge keys inside nested mappings and lists, and drops the << key"""
    doc = {"jobs": [{"name": "test", "<<": BASE}], "base": BASE}
    result = apply_merge_keys(doc)
    assert result["jobs"][0] == {"name": "test", "image": "ruby:4.0", "retries": 2, "env": {"CI": "true"}}
    assert "<<" not in result["jobs"][0]
    assert result["base"] == BASE


def test_inputs_stay_unchanged():
    """builds new mappings and leaves the input as it was"""
    job = {"<<": BASE, "retries": 3}
    apply_merge_keys(job)
    assert job == {"<<": BASE, "retries": 3}
    assert BASE["retries"] == 2


def test_a_value_that_cannot_be_merged():
    """refuses a merge key whose value is not a mapping or a list of mappings"""
    try:
        apply_merge_keys({"<<": [1, 2]})
    except ValueError:
        return
    assert False, "a list of numbers is not a list of mappings: expected ValueError"
A hint

Recurse first: a list becomes a list of processed items, a plain value stays as it is. For a dictionary, copy every key except "<<" (processing each value), then go through the merged mappings in order and add only the keys that are not there yet. isinstance(sources, dict) tells a single mapping from a list.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The session below mixes anchors, sharing, merge rules, tool support and expansion limits, and begins with the topic you have answered least well so far:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Which line reuses the node that &base marked?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: test: *base

    & names a node and * reuses it. Both are followed directly by the name, with no space.

  2. Question 2 of 10 What is the value of c?

    Read the code, then choose one answer.

    a: &x 1
    b: &x 2
    c: *x
    Show the answer: Question 2 of 10

    Answer: 2

    The specification ties an alias to the most recent preceding node with the same anchor, so reusing an anchor name is allowed but easy to misread. Psych gives c the value 2.

  3. Question 3 of 10 What does an alias give the program that loads the document?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: The same object as the anchored node

    Aliases are shared references in the loaded data, in Ruby as in Python. Copy before changing.

  4. Question 4 of 10 Jobs in a pipeline file share an anchored node. You must raise the retries of the test job only, in the program. What do you do?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: Copy the test job deeply, then change the copy

    With aliases, the data is a graph rather than a tree. Copy the part you change, as the lesson's Marshal copy and the Python dict() copy did.

  5. Question 5 of 10 By the merge type's rule, a mapping and a mapping it merges both have retries. Which value wins?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: The mapping's own value

    Own keys win, then earlier mappings of a list, and nested mappings are replaced whole.

  6. Question 6 of 10 In Psych 5.3.1, the job late writes retries: 9 and then <<: *base, where base has retries 2. What does late get?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: retries 2, because Psych applies the merge in the order of the lines

    Write << first in its mapping, before the keys that override it: then Psych and the rule agree.

  7. Question 7 of 10 The yaml package for JavaScript loads a YAML 1.2 document that uses <<, with its default options. What does its documentation say happens?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Nothing is merged: the merge option is false by default for YAML 1.2

    Libraries that support both versions usually tie merge keys to YAML 1.1. Read the option before relying on it.

  8. Question 8 of 10 What does the lesson recommend for GitHub Actions workflow files?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Use anchors and aliases, which the documentation describes, and leave out <<

    Use the features a tool documents. For whole reused nodes, aliases are enough; for small differences, repeat the keys.

  9. Question 9 of 10 Which default protects a Psych loader from alias expansion?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: safe_load refuses every alias unless the program passes aliases: true

    Two kinds of limit exist: no aliases at all (Psych's default) and a count (maxAliasCount, or a guard such as guarded_load).

  10. Question 10 of 10 Your service accepts YAML uploads from users and converts them to JSON. Which plan keeps alias expansion bounded?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Cap the upload size, then load with aliases off, or count the expanded nodes on the parse tree and refuse above a limit

    Bound the input, then bound the expansion before any value is built, and refuse with a clear error.

Interview questions

Warm-up (fresher to 2–5 years): anchor, alias and merge key: what does each one do?

An anchor, &name, marks a node so that it can be reused. An alias, *name, stands for the most recent node with that anchor earlier in the same document; it is not a copy, so a loader builds the node once and shares it, and a change through one alias shows up through all of them. A merge key, <<: *name or <<: [*a, *b], copies the keys of other mappings into the current one, except keys the mapping already has; with a list, earlier mappings win, and nested mappings are replaced whole. Anchors and aliases are part of YAML 1.2. The merge key is a YAML 1.1 type that YAML 1.2 removed, so some tools apply it and others keep << as an ordinary key.

Two questions follow that start from things that went wrong: a script that changed more jobs than it meant to, and a small file that took a tool’s memory. Think through the first step of each before you read it:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A script loads the CI pipeline file, raises the timeout of the integration job, and saves the file. After it runs, every job has the new timeout, and the diff also renames anchors to &1 and &2. Explain both effects and rewrite the script so it changes one job and nothing else.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A converter accepts YAML files of up to 10 KB from users and returns JSON. A 400-byte upload makes it run out of memory. Explain how that can happen, and design the limits you would add, with the order in which they run.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What do &name and *name do in YAML?

    &name labels a node; *name stands for the most recent node with that label earlier in the same document.

  2. Is the data behind an alias a copy?

    No, it is one shared object: a change through one alias shows through all of them. Dumping writes the sharing back with generated anchors such as &1.

  3. What does Psych's safe_load do with aliases by default?

    It refuses them with Psych::AliasesNotEnabled until the program passes aliases: true.

  4. Which keys does a merge key << add to a mapping?

    Only the keys the mapping lacks. With a list of mappings the earlier one wins, and a nested mapping is replaced whole, not merged.

  5. Why write << as the first key of a mapping?

    Psych applies a merge in line order, so with << first every tool that supports merge keys gives the same result.

  6. Are merge keys part of YAML 1.2?

    No. They are a YAML 1.1 type that YAML 1.2 removed, so one tool merges while another keeps << as an ordinary key.

  7. How do you keep a small YAML file from expanding into a huge one?

    Count the expansion on the parse tree, where an alias is still one node, and refuse files above a limit; 312 bytes became 502,441 bytes of JSON in the lesson. Or keep aliases off for files you did not write.

Key takeaways

  • &name marks a node and *name reuses the most recent node with that anchor in the same document; Psych refuses aliases until the program allows them.
  • An alias is the same object, not a copy: after one change both jobs read 5, and YAML.dump writes the sharing back with its own anchors such as &1.
  • A merge key adds only the keys a mapping lacks, takes them from the earlier mapping of a list first, and is shallow; Psych applies it in line order, so put << first.
  • The merge key is YAML 1.1 and was removed in YAML 1.2, so check every tool that reads a file before using it.
  • Aliases multiply data: 312 bytes became 502,441 bytes of JSON; count the expansion on the parse tree and refuse files above a limit, or keep aliases off for files you did not write.

Terms in this lesson

alias
A YAML node written *name that stands for the most recent node anchored with that name earlier in the document. A loader shares that node rather than copying it.
alias expansion
The growth of YAML data when its aliases are followed and copied, as when it is written out as JSON: each level of aliases multiplies the size, so a short file can become very large.
anchor
A name given to a YAML node with &, such as &defaults, so that the node can be reused later in the same document by an alias.
merge key
The YAML 1.1 key <<, whose value is a mapping or a list of mappings whose keys are copied into the current mapping, except keys it already has. YAML 1.2 removed it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.