🍱 Lunchbox Hands

yaml

The YAML Norway Problem and Other Values That Change Type

Why NO becomes false, 0755 becomes 493 and 1:30 becomes 90 in YAML 1.1 parsers but not in YAML 1.2, with real output from PyYAML, Ruby Psych, and the yaml npm package.

You put a list of country codes in a YAML file: GB, NO, SE. A Python service loads it and gets ['GB', False, 'SE']. Norway has become a boolean. YAML’s type traps are not parse errors. The file is valid, but different parsers disagree about what the unquoted values mean, because some implement YAML 1.1 and others implement YAML 1.2. Nothing crashes. You just get false where you expected a country.

That’s why a validator can’t simply say “valid” and stop. This post goes through the values that change type depending on which parser reads them: booleans, octal numbers, base-60 times, and version numbers. Then it covers the syntax traps that are real errors, like tabs, colons, #, and duplicate keys. All the output below comes from running actual parsers: PyYAML 6.0.3, Ruby’s Psych 5.3.1 (Ruby 4.0.7), and version 2.8.2 of the yaml npm package, which is the library behind our YAML validator and YAML ⇄ JSON converter.

Two YAML versions, two sets of rules

The YAML 1.1 type repository defines booleans with this regular expression, from yaml.org/type/bool.html:

y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF

YAML 1.2 (2009; the current revision, 1.2.2, is from 2021) says its “primary focus was making YAML a strict superset of JSON” and that it “removed many of the problematic implicit typing recommendations.” The Core schema in §10.3, which the spec calls “the recommended default schema that YAML processor should use,” accepts only true | True | TRUE | false | False | FALSE as booleans. Every other plain scalar that doesn’t match one of its patterns “is resolved to tag:yaml.org,2002:str”. In other words, it’s a string.

The spec changed, but many parsers still implement 1.1:

ParserFollowsSource
yaml (npm, eemeli)1.2 Core by defaultdocs: version defaults to '1.2', schema 'core'
PyYAML1.1PyPI: “a complete YAML 1.1 parser”
Ruby Psych (libyaml backend)1.1 booleansscalar_scanner.rb: “The default libyaml backend keeps the 1.1 set for backward compatibility”
SnakeYAML (Java)1.1README: “a YAML 1.1 processor”; SnakeYAML Engine is the 1.2 one
Go gopkg.in/yaml.v21.1resolve.go maps y, yes, on, … to bool
Go gopkg.in/yaml.v3”most of YAML 1.2” plus 1.1 exceptionsREADME (the repo was archived in April 2025)

The Norway problem

Here’s one file with seven traps in it:

countries:
  - GB
  - NO
  - SE
enabled: on
answer: y
mode: 0755
zip: 02134
duration: 1:30
version: 1.10

This is what each parser actually returned:

Valueyaml 2.8.2 (1.2 Core)PyYAML 6.0.3Ruby Psych 5.3.1
NO"NO"Falsefalse
on"on"Truetrue
y"y"'y'"y"
0755755493493
02134213411161116
1:30"1:30"905400
1.101.11.11.1

NO and on are the classic Norway problem: YAML 1.1 booleans that are strings in 1.2. y is more interesting. It’s in the 1.1 spec’s regex, but PyYAML and Psych both keep it as text. Go’s yaml.v2 includes y, Y, n, and N in its bool table. yaml.v3 goes the other way: its README says 1.1 bools “are supported as long as they are being decoded into a typed bool value. Otherwise they behave as a string.” So with a single-letter value, three parsers can give you three different answers.

The same thing happens to keys, which catches out GitHub Actions workflows. on: push loaded with PyYAML gives {True: 'push'}, and Psych gives {true => "push"}. If you process workflow files with a Python or Ruby script, the key "on" doesn’t exist. It has become the boolean True. Our validator deliberately doesn’t warn about keys, so on: in a workflow stays quiet. That’s a trade-off to cut noise, and it’s worth knowing about if your tooling is Python.

The fix is always the same: quote it. "NO", "on", and "y" are strings in every parser, in every version.

Octal: 0755 is 493, unless it’s 755

YAML 1.1’s int type treats a leading zero as octal ([-+]?0[0-7_]+). YAML 1.2 Core only accepts octal with an explicit prefix, 0o [0-7]+, and reads [-+]? [0-9]+ as base 10. So mode: 0755 gives two different numbers, and both look plausible:

  • 1.2 Core (yaml): 755
  • PyYAML and Psych: 493 (7×64 + 5×8 + 5)

The obvious fix is the 1.2 spelling 0o755, but it doesn’t travel. PyYAML returns '0o755' and Psych returns "0o755", both as strings. Go’s yaml.v3 goes further and keeps writing 1.1 style: “Octals encode and decode as 0777 per YAML 1.1, rather than 0o777 as specified in YAML 1.2, because most parsers still use the old format.” No unquoted spelling means “octal 755” to all of these parsers. Ansible’s file module docs reach the same conclusion: “quote octal numbers (for example, '644' or '1777') so Ansible receives a string and can do its own conversion,” and a leading zero “works sometimes, but can fail in loops and some other circumstances.”

Leading zeros cause trouble even when you don’t mean octal. The ZIP code 02134 becomes 1116 in PyYAML and Psych and 2134 in a 1.2 parser. Neither is a ZIP code. 08 is odder still: 8 and 9 aren’t octal digits, so the 1.1 octal pattern doesn’t match and PyYAML and Psych both return the string '08', while a 1.2 parser returns the number 8. Quote IDs, ZIP codes, and phone numbers.

Base 60: 1:30 is 90, or 5400

YAML 1.1 has sexagesimal integers: “Using ’:’ allows expressing integers in base 60, which is convenient for time and angle values.” The pattern is [-+]?[1-9][0-9_]*(:[0-5]?[0-9])+, so 1:30 is 1×60 + 30 = 90. That’s what PyYAML returns, and 12:30:45 becomes 45045.

Ruby’s Psych returned 5400 for 1:30. Its scanner weights each part by 60 ** (e - 2).abs, which assumes three parts. With only two, the first part is multiplied by 3600, so 1:30 is read as 1 hour 30 minutes in seconds (3600 + 1800). Three-part values agree with PyYAML (12:30:45 → 45045 in both), and so does the spec’s own example 190:20:30 → 685230. The spec gives no two-part example to check the 5400 against, but the 1.1 regex allows 1:30, and the plain base-60 reading of it is 90.

YAML 1.2 dropped base 60 entirely, and go-yaml v3 “purposefully” doesn’t support it, calling it “a poor design.” In our parser, 1:30 stays the string "1:30". If you mean a duration or a time of day, quote it.

Version numbers that lose a digit

version: 1.10 is a float in every parser we ran, and floats have no trailing zeros: it comes back as 1.1. Here YAML 1.1 and 1.2 agree, and both are wrong for your purposes. Python 3.10 and Node 1.20 have the same problem. Our linter doesn’t flag this one, because 1.10 is a perfectly good number and it can’t tell a version from a measurement. Look at the JSON preview: if it shows 1.1, add quotes.

#, colons, and tabs: the syntax traps

These traps really are syntax, and they behave the same in 1.1 and 1.2.

A comment needs whitespace before it. The 1.2 spec: “Comments must be separated from other tokens by white space characters,” and “plain scalars must never contain the ’: ’ and ’ #’ character combinations.” So:

a: value #comment        # → "value"
b: value#nocomment       # → "value#nocomment"
color: #ff0000           # → null (the whole value is a comment)
url: http://example.com#frag   # → "http://example.com#frag"

color: #ff0000 is the nasty one. It parses without an error, and the value is null. Our validator flags it: "#ff0000" is a comment, so the value is null.

A colon followed by a space starts a mapping. title: Note: read this fails in the yaml package with Nested mappings are not allowed in compact mappings and in PyYAML with mapping values are not allowed here. A colon with no space after it (http://…, 10:30) is fine. Quote the whole value when it contains : .

No tabs in indentation. The spec says “tab characters must not be used in indentation,” and our validator reports Tabs are not allowed as indentation with the line and column.

Duplicate keys. The spec requires keys to be unique. Our parser rejects a: 1 followed by a: 2 with Map keys must be unique. PyYAML accepts it silently and returns 2, so in a long file a copy-pasted key can override the setting you thought you had.

Multiple documents and merge keys

--- starts a new document. Kubernetes manifests rely on this, but single-document loaders don’t handle it. PyYAML’s safe_load raises expected a single document in the stream. The npm package’s YAML.parse throws Source contains multiple documents; please use YAML.parseAllDocuments(). Use safe_load_all or parseAllDocuments. Our validator does, reports how many documents it found, and shows each one in the JSON preview.

<< merge keys are a YAML 1.1 feature. They’re defined in the 1.1 type repository (“Specify one or more mappings to be merged with the current one”) and aren’t part of the 1.2 Core schema. The yaml package’s docs say merge support defaults to true for 1.1 and false for 1.2. So given:

base: &b
  x: 1
child:
  <<: *b
  y: 2

PyYAML returns child: {'x': 1, 'y': 2}. Our validator and converter use the 1.2 defaults and return "child": { "<<": { "x": 1 }, "y": 2 }, with a literal << key. That’s correct for 1.2, but it isn’t what a Compose-style consumer expects. Our tools don’t expand merge keys, so if the JSON has a "<<" key, that’s why. The anchor and alias themselves resolve fine. An alias without a matching anchor is reported as an error.

What our validator catches, and what it can’t

Our YAML validator parses with YAML 1.2 Core and then runs a lint pass for values that a 1.1 parser would read differently. Here’s what the lint code checks:

TrapFlagged?Warning text (real output)
yes/no/on/off values, any caseYesUnquoted NO is a string in YAML 1.2 but a boolean (false) in YAML 1.1
Single y/n valueYesUnquoted y may be read as a boolean by YAML 1.1 parsers
Leading-zero octal digitsYes0755 is 755 here but 493 (octal) in YAML 1.1
Leading zero with 8 or 9Yes08 will lose its leading zero (parsed as 8)
Base-60Yes1:30 is text here but a base-60 number in YAML 1.1
key: #valueYes"#ff0000" is a comment, so the value is null
Tabs, duplicate keys, bad indentation, unclosed quotesYes (errors)parser message plus a plain-English hint
Multiple documentsYes2 documents in one stream
on: / yes: as keysNodeliberately skipped
1.10 → 1.1Nocheck the JSON preview
<< merge keysNoshows up as a "<<" key in the preview

There are two limits worth stating plainly. First, the linter can only warn. It doesn’t know which parser will eventually read your file, so it can’t tell you whether NO becomes false in your pipeline. It can only tell you that it would in a 1.1 parser. Second, its octal hint tells you to quote the value rather than switch to 0o755, for the reason shown above: PyYAML and Psych read 0o755 as a string. Quoting is the only fix that works everywhere, as long as the consumer converts the string itself.

The validator runs entirely in your browser, so config files with secrets in them never leave your machine.

A quoting rule that avoids all of this

If a value is meant to be text and it looks like a boolean, a number, a time, or a version, quote it: "NO", "0755", "02134", "1:30", "1.10", "#ff0000". Double-quoted strings are strings in YAML 1.1, 1.2, and every parser in the tables above.

To check a file, paste it into the YAML validator and read the warnings next to the JSON preview. If you’re converting rather than checking, the YAML ⇄ JSON converter shows the same 1.2 interpretation. JSON has none of these ambiguities, since "NO" and false look different, and our JSON validator checks the other side. Other config formats have their own versions of this problem. INI has no spec at all, so every INI ⇄ JSON conversion has to guess whether yes is a boolean (INI round-trip gotchas). .env files disagree about # in much the same way (dotenv parsing differences). TOML avoids implicit typing and has different traps instead (TOML to JSON gotchas).