OnlineTextLab
Esc

YAML Pitfalls: The Norway Problem, Implicit Types and Indentation

Updated 6 min read How we check our guides

Most YAML bugs have the same cause: a value you meant as text gets read as something else because it was not quoted. Quote every string that could look like a number, boolean, date or null, and most of this article stops applying to you. The rest is about knowing which YAML version your parser speaks, because the same file can produce different data in two parsers.

What the Norway problem looks like

Here is a small file. We loaded it with PyYAML 6.0.3, which implements YAML 1.1, and with the JavaScript yaml library 2.9.0 in its default YAML 1.2 mode:

country: NO
on: push
enabled: yes
version: 1.10
mode: 0755
time: 12:30:00
date: 2024-01-15
sci: 1e3
sha: 12e4567
InputPyYAML (YAML 1.1)yaml library (YAML 1.2)
country: NOFalse"NO"
on: pushkey is Truekey is "on"
enabled: yesTrue"yes"
version: 1.101.11.1
mode: 0755493 (octal)755 (decimal)
time: 12:30:0045000 (base 60)"12:30:00"
date: 2024-01-15a date object"2024-01-15"
sci: 1e3"1e3"1000
sha: 12e4567"12e4567"Infinity

The first row is the famous one. A list of country codes works until Norway arrives, then NO becomes the boolean false. Under YAML 1.1, these all count as booleans in any common casing: y, n, yes, no, on, off, true, false. (PyYAML does not actually treat bare y and n as booleans, though the 1.1 type specification lists them.)

Look at the on row too. A GitHub Actions workflow has a top level on: key. Read that file with PyYAML in your own script, and doc["on"] raises KeyError, because the key is the boolean True.

The version row catches people in both modes. 1.10 is a valid float in every version of YAML, so it becomes 1.1. This regularly bites language version pins in CI files, where python-version: 3.10 turns into 3.1. Write "3.10".

The last row shows that 1.2 has traps of its own. A short Git hash that looks like scientific notation, such as 12e4567, overflows to infinity. A hash like 1234567 is an integer in every version.

YAML 1.1 versus 1.2

YAML 1.2 came out in 2009 and removed most of the surprises above. Its core schema recognizes only true and false as booleans, uses 0o755 for octal, and drops base 60 numbers.

Adoption is uneven, so “this is valid YAML” tells you little about what your data will become. Check what your specific parser does:

  • PyYAML targets 1.1, and it is what much of the Python world uses.
  • yaml for JavaScript defaults to 1.2 and can be switched: with {version: "1.1"}, it gave us country: false, as PyYAML did.
  • js-yaml 4 follows 1.2 for booleans and octal (NO stayed a string, 0755 became 755), but kept the date value as a timestamp in our test, and merges << keys.

When a project mixes languages, one config file can be read two ways. The only defense that works everywhere is quoting.

Indentation errors, including the silent ones

YAML forbids tabs for indentation. PyYAML gives a scanner error for a: 1 followed by a tab-indented line, which at least tells you where to look.

Inconsistent spaces can fail loudly or quietly. This one fails in PyYAML, with “while parsing a block mapping”:

a: 1
b:
  c: 2
 d: 3

The quiet kind is worse. Move a key one level out and the file still parses, but your setting now belongs to a different parent:

server:
  host: example.com
  tls:
    enabled: true
  cert: /etc/ssl/cert.pem   # meant to be under tls

No parser can catch that, because it is valid. A JSON Schema with additionalProperties: false can, and it is worth adding to any config that matters.

Duplicate keys are also quiet in many parsers. PyYAML loaded dup: 1 then dup: 2 as {"dup": 2}, with no warning, even though the spec says keys must be unique. A linter such as yamllint flags it, and so does our validator.

A list at the same indent as its parent key is valid, which surprises people:

items:
- a
- b

Characters that change meaning

A colon followed by a space starts a mapping. title: Hello: world fails with “mapping values are not allowed here”. Quote it.

A hash preceded by a space starts a comment. We got these results from PyYAML:

x: #fff      # None, the value is gone
y: '#fff'    # "#fff"
z: a #b      # "a"
w: a#b       # "a#b"

CSS colors are the classic casualty. There is no error, just a null.

Block scalars control line handling. | keeps line breaks, > folds them into spaces, and a trailing - strips the final newline. |- is usually what you want when pasting a script or key, since | adds a newline at the end, and that can break a comparison.

Anchors, aliases and merge keys

Anchors let you reuse a block:

base: &b
  a: 1
  c: 1
child:
  <<: *b
  c: 2

PyYAML and js-yaml give child the value {a: 1, c: 2}. The yaml library, in default mode, returned {"<<": {a: 1, c: 1}, c: 2}, a literal key named <<, and only merged when given {merge: true}. The reason is that << was a separate YAML 1.1 type and never made it into the 1.2 spec. If you rely on merge keys, test them on the parser that will read the file in production.

Aliases also enable a denial of service attack, the YAML version of the billion laughs. We wrote a 306-byte file with nine levels of nested aliases, each with nine references, which expands to 387,420,489 leaf values. The yaml library refused it with “Excessive alias count indicates a resource exhaustion attack”. Not every parser has a limit like that, so limit the input size if you accept YAML from users.

Never load untrusted YAML with a full loader

Some loaders can build arbitrary objects from tags. In PyYAML, yaml.load with an unsafe loader can run code from a tag like !!python/object/apply:os.system. We confirmed that yaml.safe_load refuses this with “could not determine a constructor”. Use safe_load, always.

What our YAML tools can and cannot tell you

Our YAML Validator and YAML to JSON converter read YAML by the 1.2 rules. They cover the things that matter in config files: block scalars, anchors, merge keys, flow collections and multiple documents. Duplicate keys and tab indentation are errors with a line number. PyYAML, by contrast, lets duplicates through.

The validator also shows how a parser reads your file, as JSON, which is the quickest way to catch the misplaced key above. It flags values such as NO and yes with a note that older parsers read them as booleans.

Keep in mind that this is a 1.2 reading. For the sample at the top of this article, our converter returned "NO", "yes", "12:30:00" and 1.1. PyYAML would give you False, True, 45000 and 1.1. If your file will be consumed by a 1.1 parser, the only true test is that parser. We also found two cases where it departs from other 1.2 libraries: 0755 stayed the text "0755", and 12e4567 stayed text instead of becoming infinity. Both are safer outcomes, but different ones, so don’t assume every parser agrees.

Going the other way, JSON to YAML adds quotes around risky strings: "NO", "1.10" and "true" all came out quoted in our test, so they stay strings when read back by any parser.

Rules that prevent nearly all of it

  1. Quote every string value that isn’t obviously a plain word, and in particular versions, codes, hashes, times and anything starting with 0.
  2. Use only true and false for booleans.
  3. Run yamllint in CI to catch duplicates and indentation.
  4. Validate the parsed result against a schema, as parsing alone can’t tell you what was meant.
  5. If people don’t need to edit the file by hand, generate JSON instead.

To see what a parser made of your file, convert it to JSON and read the output. Types are visible at once, as "NO" and false look nothing alike.