A YAML validator answers whether the file parses. That is rarely the question. The file parses, the deploy goes through, and the country code NO arrives in your service as the boolean false. This tool checks what the values mean rather than whether the syntax holds, and shows you both readings side by side.
What the linter checks
Two things, and they need different machinery.
The first is the schema split. Every unquoted value in YAML is resolved to a type by pattern matching, and the patterns changed between YAML 1.1 and YAML 1.2. The linter applies both rule sets to every plain scalar in your file and lists the ones where the answers differ, with the actual resolved value on each side. Neither side comes from a library here: both are implemented from the two specs, so the report says what PyYAML does and what the JS yaml package does rather than what one of them happens to do.
The second is everything a type table cannot see: keys set twice, tabs in the indentation, a non-breaking space that looks like a space, an alias without its anchor, an anchor nobody uses, a value that was swallowed by a comment, an integer past the point where JavaScript keeps it exact.
How to read the report
- Schema split. One row per ambiguous value with its line and column, the raw text, and what YAML 1.1 and YAML 1.2 each make of it. This is the part to send to whoever says the file is fine.
- Findings. Grouped by cause rather than by line, so twelve unquoted country codes are one entry with twelve line numbers and one explanation.
- Quoted. Your file with every ambiguous value single-quoted, ready to copy or download.
--schema-diff
Switches the version comparison on. Leave it on unless you are certain that every consumer of the file is a YAML 1.2 parser, which in practice means no Python, no Ruby and no older Go in the chain.
--quote
Produces the fixed file. Single quotes are used because they need no escaping beyond doubling a quote character, and because they pin the value as a string in both versions. Values that both versions already agree on are left alone, so the diff stays small; the one exception is a float that loses a digit, such as 1.10, where both versions agree and both are wrong for what you meant.
Why two parsers disagree
YAML 1.1 is from 2005 and carries a type system that looked reasonable at the time: octal and binary integers, sexagesimal numbers for durations, timestamps, and a generous set of boolean spellings borrowed from configuration files of the era. YAML 1.2 arrived in 2009, redefined the language as a superset of JSON and cut all of it down to a core schema with null, bool, int and float, spelled the way JSON spells them.
The catch is that libraries did not follow. Sixteen years later PyYAML still implements 1.1 and has no option to do otherwise, which means the single most used YAML parser in the world reads the old rules. go-yaml v2 is 1.1, v3 moved most of the way to 1.2. SnakeYAML is 1.1, with SnakeYAML Engine as the separate 1.2 implementation. The JS yaml package defaults to 1.2 core.
So a Kubernetes manifest, a GitHub Actions workflow and an Ansible playbook can all be read by different parsers on the way to production, and the file that survives one of them is not guaranteed to survive the next.
The values that change meaning
| You write | YAML 1.1 (PyYAML, Psych, go-yaml v2) | YAML 1.2 core (JS yaml, go-yaml v3) |
|---|---|---|
no, NO, off, y, N | boolean | string |
0644 | 420 (octal) | 644 (decimal) |
08 | "08" (invalid octal, stays text) | 8 |
0o644 | "0o644" | 420 (octal) |
12:30 | 750 (base 60) | "12:30" |
1e5 | "1e5" | 100000.0 |
1.0e10 | "1.0e10" (exponent needs a sign) | 10000000000.0 |
1_000_000 | 1000000 | "1_000_000" |
0b1010 | 10 | "0b1010" |
2024-06-01 | a date object | "2024-06-01" |
1.10 | 1.1 in both, and the trailing digit is gone | |
The last row is the one people find hardest to believe, because there is no disagreement to blame. Both versions read 1.10 as a float, and floats have no trailing zero. A version number is a string that happens to contain a dot, and treating it as a number reverses the ordering as well: 1.10 belongs after 1.9 and sorts before it.
The on: key in a GitHub Actions workflow is a second case worth naming. Under YAML 1.1 that key is the boolean true, so a workflow dumped with PyYAML and reloaded comes back with True: where on: used to be. GitHub handles it; yq, a linter or your own script may not.
Duplicate keys, tabs and anchors
Duplicate keys are the quiet one. The spec says keys are unique, and it does not require the parser to complain, so behaviour ranges from silently keeping the last one (PyYAML, Ruby Psych, the JS yaml package) to erroring out (go-yaml v3) to rejecting the manifest (the Kubernetes API server). A config with two replicas: lines runs with a value you did not pick, and nothing in the file looks unusual.
Tabs are the loud one. A tab in the indentation is illegal and every parser stops, but the error text almost never mentions tabs, so people go looking at the wrong line. A non-breaking space is worse than either: it is legal, it is not a space, and it is invisible. It arrives by copying a snippet out of a browser or a chat window, and it turns the line into part of the value above it.
Anchors and aliases are useful and worth two cautions. An alias must come after its anchor in the same document, and it does not reach across a --- separator, which is what usually breaks a bundled multi-document manifest. And an alias nested inside an anchored node multiplies on expansion, which is the billion laughs attack: a few hundred bytes of YAML that turn into gigabytes in memory. The linter flags an alias used eight times or more for that reason.
When to quote, and how
Quote anything that identifies something rather than counting something.
- Always quote: versions, dates, times and durations, git hashes, country and language codes, zip and phone numbers, account numbers, file modes, anything with a leading zero, anything with a colon, anything starting with
#,*,&,!,%or@. - Leave unquoted: real numbers you will do arithmetic with, real booleans written
trueandfalse, and ordinary prose. - Single quotes by default. The only escape inside them is a doubled quote, so
'it''s'and nothing else. Double quotes bring the backslash escapes with them, which you want for\nand not much else.
Knowing the rules helps less than these two moves. Round-trip the file through the parser your service actually uses and look at the output once, which takes a minute and catches everything above. And keep yamllint in CI for the style layer, remembering that it checks form rather than meaning: it will not tell you that NO became false, because as far as YAML is concerned nothing went wrong.
The guide to the Norway problem has the history and the incident reports. If you also need the file reformatted, the YAML formatter keeps comments and anchors intact, and the YAML to JSON converter shows you the resolved values directly.
Values that change meaning
What is the YAML Norway problem?
YAML 1.1 resolves ten spellings of true and false, including y, Y, yes, no, on and off. A list of country codes therefore turns NO into the boolean false, which is where the name comes from. YAML 1.2 removed all of them and keeps only true and false, so the same file means two different things depending on the parser. PyYAML, Ruby Psych, SnakeYAML and go-yaml v2 follow 1.1; the JS yaml package, go-yaml v3 and serde_yaml follow 1.2.
Why does my YAML boolean become a string, or the other way around?
Because the two YAML versions have different rules and libraries pin different versions. enabled: no is the boolean false in PyYAML and the string "no" in the JS yaml package. Neither is wrong, and neither warns you. Quoting the value makes it a string everywhere; writing true or false makes it a boolean everywhere. Anything else is a coin flip decided by the consumer.
Why does YAML turn 0644 into 420?
YAML 1.1 reads a leading zero as an octal literal, and 0644 in octal is 420 in decimal. It is the correct reading for a Unix file mode and the wrong one for a zip code or an account number. YAML 1.2 changed the spelling: octal is written 0o644, and 0644 is read as the decimal 644. The same file therefore produces 420, 644 or "0644" depending on who reads it.
Why did my version number 1.10 become 1.1?
Because unquoted it is a float, and 1.10 and 1.1 are the same float. Version numbers are not decimal numbers: 1.10 comes after 1.9, and the moment it becomes a float that ordering is gone along with the trailing digit. The same applies to python: 3.10, which is why "3.1" turns up in build logs of projects that meant Python 3.10. Quote it.
Why is my git SHA read as a number?
A commit hash that happens to start with digits and contain a single e, such as 1e5 or 20e12, matches the YAML 1.2 float pattern and comes back as 100000. YAML 1.1 required a decimal point and a signed exponent, so it keeps the string there. Short SHAs hit this often enough that pinning them unquoted in a config is a known way to get a build that works on one machine and not another.
Why does 12:30 become 750?
YAML 1.1 has a sexagesimal number type for base-60 values, so 12:30 is read as 12 times 60 plus 30. Durations, cron-like times and anything else written with colons are affected. YAML 1.2 dropped the type entirely and keeps the string. There is no spelling that means the same in both other than quoting it.
What happens to duplicate keys in YAML?
It depends on the parser, and only some of them tell you. PyYAML and the JS yaml package keep the last occurrence silently, Ruby Psych does the same, go-yaml v3 returns an error, and the Kubernetes API server rejects the manifest. The YAML 1.2 spec says keys must be unique but does not require an error, so a config that works locally can be refused by the cluster. Nothing in the file looks wrong, which is what makes it expensive to find.
Why does YAML reject tabs?
Indentation in YAML must be spaces, and a tab in the indentation is a hard error in every parser. The message is rarely helpful: PyYAML says "found character \t that cannot start any token" and the JS yaml package reports a bad indentation. The usual source is an editor without a YAML-specific setting, or a snippet pasted from a terminal. Set your editor to insert spaces for .yaml and .yml and the problem goes away for good.
What does the merge key << do?
It copies the keys of the referenced mapping into the current one, one level deep, so <<: *defaults gives you the defaults plus whatever you override below it. Two things to know: nested mappings are shared rather than copied, so a later edit to one shows up in the other; and it is a YAML 1.1 extension that never made it into the 1.2 core schema, so go-yaml v3, PyYAML and the JS yaml package support it while serde_yaml and some Kubernetes tooling do not.
What is the billion laughs attack in YAML?
An anchor referenced several times inside another anchored node, nested a few levels deep. Each level multiplies, so a file of a few hundred bytes expands into gigabytes when the parser resolves the aliases, and the process dies. PyYAML has no limit by default, which is why safe_load does not protect you here. If YAML arrives from outside your system, use a parser with an expansion cap or reject aliases entirely.
Why is my value null when I wrote something after the colon?
A # preceded by whitespace starts a comment, so color: #fff and password: #secret both leave the key with the value null and produce no error at all. Any value beginning with a hash has to be quoted: color: "#fff". The reverse mistake exists too, and is quieter: value# comment is not a comment, and the hash ends up in the string.
Is YAML 1.2 the version my parser uses?
Usually not, despite 1.2 being from 2009. PyYAML implements 1.1 and there is no flag to change that; ruamel.yaml defaults to 1.2 and can be told to read 1.1; go-yaml v2 is 1.1 and v3 is closer to 1.2 while still resolving some 1.1 types; SnakeYAML is 1.1 with SnakeYAML Engine as the 1.2 implementation; the JS yaml package defaults to 1.2 core and takes a version option. Checking which one your stack uses is a five-minute job that saves an afternoon.
Should I just quote everything in YAML?
Quote every string that is not obviously prose, and leave numbers and booleans you actually mean as numbers and booleans. Versions, dates, times, IDs, hashes, zip codes, file modes, country codes and anything with a leading zero or a colon belong in quotes. A blanket "quote everything" rule also works and costs you readability in list-heavy files, which is why most style guides land on the narrower version.