Measured 17 September 2026
The Norway problem, and other ways YAML changes your config
I ran 11 ordinary config snippets through two real parsers — PyYAML 6.0.3, which follows YAML 1.1, and this site’s own parser, which follows YAML 1.2. They produced different values for 8 of them.3 of those disagreements came with no warning from either side. The worst case is not a value at all — it is a key that renames itself.
The one that breaks workflows
Start with the snippet that a great many people have written without knowing it is ambiguous. This is the trigger block of every GitHub Actions workflow:
on: push| Parser | Result |
|---|---|
| YAML 1.1 (PyYAML 6.0.3) | {"true": "push"} |
| YAML 1.2 (this site) | {"on": "push"} |
The worst one, because it is the *key* that changes rather than the value. A 1.1 parser resolves the unquoted key `on` to boolean true and then stringifies it for use as a mapping key, so the block you wrote is no longer reachable under the name you gave it. Everything downstream still parses. It simply cannot find on, because there is no longer a key by that name.
All 11 snippets
Each of these is something a person would plainly write meaning one thing. The last column is what this site’s parser said about it, which is the difference between a surprise and a warning.
| You wrote | YAML 1.1 reads | YAML 1.2 reads | Flagged? |
|---|---|---|---|
on: push | {"true": "push"} | {"on": "push"} | ambiguous-boolean |
country: NO | {"country": false} | {"country": "NO"} | ambiguous-boolean |
debug: yes ⏎ cache: off | {"debug": true, "cache": false} | {"debug": "yes", "cache": "off"} | ambiguous-boolean, ambiguous-boolean |
employee: 012345 | {"employee": 5349} | {"employee": "012345"} | — |
mode: 0777 | {"mode": 511} | {"mode": "0777"} | — |
standup: 9:30 | {"standup": 570} | {"standup": "9:30"} | — |
version: 1.20 | {"version": 1.2} | {"version": 1.2} | — |
initial: N | {"initial": "N"} | {"initial": "N"} | ambiguous-boolean |
port: 8080 ⏎ host: localhost ⏎ port: 9090 | {"port": 9090, "host": "localhost"} | {"port": 9090, "host": "localhost"} | duplicate-key |
defaults: &base ⏎ retries: 3 ⏎ staging: ⏎ <<: *base | {"defaults": {"retries": 3}, "staging": {"retries": 3}} | {"defaults": {"retries": 3}, "staging": {"<<": "*base"}} | anchor-or-alias, anchor-or-alias, merge-key |
server: ⏎ port: 8080 | ScannerError | {"server": {"port": 8080}} | tab-indent |
The silent ones are the dangerous ones
3 snippets produced different values in the two parsers with nothing reported by either. No error, no warning, and a result that looks entirely reasonable on inspection:
- A padded identifier —
employee: 012345becomes{"employee": 5349}under 1.1 and{"employee": "012345"}under 1.2. A leading zero means octal in YAML 1.1, so employee 012345 becomes employee 5349. Nothing warns you, the value is a perfectly plausible number, and the same applies to zip codes, account numbers and anything else zero-padded. - A file mode —
mode: 0777becomes{"mode": 511}under 1.1 and{"mode": "0777"}under 1.2. The one case where octal is what you wanted. 0777 really is 511, so the 1.1 reading is arguably right and the 1.2 string is the inconvenient one. Quote it and convert deliberately, rather than relying on either. - A time of day —
standup: 9:30becomes{"standup": 570}under 1.1 and{"standup": "9:30"}under 1.2. YAML 1.1 has a sexagesimal number type, so 9:30 is nine sixties plus thirty. 570 is a real number of minutes, which is exactly why nothing downstream complains.
That is what makes these worse than the boolean cases. employee: 012345becoming employee 5349 is not a crash and not a type error; it is a valid id for a different person, and it will pass every check that does not already know the right answer.
Where my own parser is the wrong one
One case goes the other way, and it belongs here rather than in a footnote. The converter on this site does not implement anchors, aliases or merge keys — a documented subset, not a bug, because implementing YAML 1.2 completely is a library’s job rather than a page’s. Given shared configuration:
defaults: &base
retries: 3
staging:
<<: *basePyYAML resolves the merge and returns {"defaults": {"retries": 3}, "staging": {"retries": 3}}. This site returns {"defaults": {"retries": 3}, "staging": {"<<": "*base"}} — a literal << key, which is not the configuration anyone meant. It reports all three issues, and that reporting is the only thing standing between you and a silently wrong file. The lesson for using it: the issues list is not decoration. If it names an anchor or a merge key, the output is not usable and a full YAML library is the right tool.
The tab case diverges in the same direction and is less serious: YAML forbids tabs for indentation, PyYAML refuses the whole document, and this site accepts it and tells you. Lenient is friendlier in a converter and would be wrong in a deployment pipeline.
"YAML 1.1" is not one behaviour either
The YAML 1.1 specification lists bare y and n as booleans, but PyYAML’s resolver omits them, so it keeps the string. This site’s parser warns anyway. That disagreement is the real lesson: "YAML 1.1" is not one behaviour, and a parser can differ from the specification it claims. It is worth keeping in mind whenever someone says a document is valid YAML: the specification version tells you less than the specific parser does, and the parser reading your file in production may not be the one you tested with.
What to actually do
- Quote anything that is not obviously a number. Country codes, versions, ids, times, file modes, single letters.
country: "NO"means Norway in every parser ever written, and costs two characters. - Quote version numbers always. The one case where both parsers agreed still lost the trailing zero:
1.20became{"version": 1.2}. That is not a 1.1 problem, it is a float problem, and quoting is the whole fix. - Treat leading zeros as a red flag. Anything zero-padded — ids, zip codes, account numbers, permissions — is an octal literal waiting to happen.
- Prefer
trueandfalseoveryesandno. They mean the same thing in both specifications, which none of the alternatives do. - Check what actually parsed. Converting the file to JSON and reading the result is the fastest way to see what a parser really did with it, which is the job YAML to JSON exists for.
Limits of this test
- Two parsers, not a survey. PyYAML for 1.1 and this site’s for 1.2. Go’s yaml.v3, JavaScript’s js-yaml, libyaml and Ruby’s Psych all make their own choices, and several are configurable. Where a row matters to you, test the parser that will actually read the file.
- A subset on one side. This site’s parser covers the shape of YAML people write in config files. Where a document goes outside it, the comparison is between a full library and a deliberately partial one, and the anchors row is that case rather than a 1.1-versus-1.2 disagreement.
- Chosen snippets. These are the classic traps, picked because they are the ones that recur. They are not a random sample of real config files, and they say nothing about how often any of this bites in practice.
- Values, not schemas. Everything here is about type resolution. A schema validator sitting after the parser will catch some of these and miss the ones where the wrong value is still the right type — which is most of them.
Repeat it yourself
The script runs both parsers over all 11 snippets and prints the table as JSON:
pip install pyyaml
npx tsx scripts/measure-yaml-ambiguity.mjsFor your own file, YAML to JSON shows the parsed result and lists every ambiguity it found, with line numbers. It runs in your browser, so a config file with credentials in it does not leave your machine to be checked — and the PDF tool study covers how to verify that kind of claim for yourself.