YAML Looks Friendly and Bites
Published 8/12/2025 · 17 min read · Developer tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 6 sources
YAML 1.2 declares JSON a subset, so every JSON document is valid YAML. What YAML adds on top is a resolution step that guesses a type for every unquoted scalar, and that guess changed between spec versions. Under YAML 1.1 the tokens y, yes, no, on and off resolve to booleans, which is the famous Norway problem: the country code NO becomes false. YAML 1.2 removed them from the core schema, so a 1.2 parser leaves them as strings. Which behaviour you get depends entirely on your parser, not on your file. Measured on one document: js-yaml 4.3.0, which describes itself as a YAML 1.2 parser, returns the string "no"; PyYAML 6.0.3, a YAML 1.1 parser, returns False. The same split hits 01234, which is 1234 under 1.2 and the octal 668 under 1.1, and 12:30:00, a string under 1.2 and the integer 45000 under 1.1. Some hazards survived the version change untouched: 1.10 is the float 1.1 in both, so a version number silently loses a digit. Add significant indentation where tabs are forbidden outright, two block scalar styles with three chomping modes, and anchors that let 403 bytes expand to 39 MB, and the practical rule writes itself: quote every string that could be read as something else.
YAML is JSON plus a type-inference layer, and the inference is the dangerous part. The same file run through a YAML 1.2 parser and a YAML 1.1 parser: no is a string in one and false in the other, 01234 is 1234 in one and 668 in the other, and 12:30:00 is a number in one of them.
A superset of JSON, plus one dangerous idea
YAML 1.2 states the relationship explicitly: JSON is a subset of YAML, and a compliant YAML processor accepts any JSON document. Run it and the claim holds — feeding {"a": 1, "b": [1,2,3]} to js-yaml returns exactly the object you would expect. So everything the previous article said about JSON's missing types applies here too, unchanged. YAML does not give you an integer type distinct from a float, or a binary type, or a schema.
What YAML adds is comfort for humans: comments, no quotes on keys, no quotes on most strings, no braces, no commas, block text that keeps its line breaks, and a reference mechanism so a value can be written once and reused. Those are real improvements for a file a person maintains, and they are why YAML runs the configuration of most of the deployment tooling in use today.
The dangerous idea is the one that makes all that possible. If keys and strings do not need quotes, then the parser has to decide what an unquoted token means, and YAML calls that step resolution: a plain scalar is matched against a set of regular expressions and assigned a type. That is where every surprise in this article comes from, and it is also the part of the language that changed between YAML 1.1 and YAML 1.2 — so the same file can mean two different things depending on which library reads it.
The Norway problem, and which spec version you actually have
YAML 1.1 defined a boolean type whose token set is generous: true and false, but also yes and no, on and off, and in the type repository the single letters y and n. A file listing country codes therefore turns Norway into a boolean, because the ISO code for Norway is NO. That is the whole of the famous bug, and it is not a parser defect — it is the specification working as written.
YAML 1.2 fixed it by shrinking the core schema. The boolean tag now matches only true and false, in a few capitalisations. Everything else stays a string. But your file does not carry a version, and almost nobody writes the %YAML directive that would declare one, so the version that applies is whichever one your library implements — and both are still in wide production use today.
So check, do not assume. This site's own YAML path uses js-yaml 4.3.0, whose package description reads "YAML 1.2 parser and serializer", and running the test document through it returns the string "no" for the Norway case — under all four of its schemas, from failsafe to default. The same document through PyYAML 6.0.3, a YAML 1.1 parser, returns the Python False. Same bytes, opposite meanings, and neither library is wrong. Take three minutes and run your own file through your own parser before you trust anything written about this online, including this.
Numbers that are not the numbers you typed
The most expensive one survived the version change untouched. Write version: 1.10 and both parsers return the floating-point number 1.1 — js-yaml gives 1.1, PyYAML gives 1.1 as a float. YAML resolved it as a number, and a number has no trailing zero, so your release 1.10 is now release 1.1 and sorts before 1.2 and 1.9. Quote it and it survives: "1.10" comes back as the string 1.10 in both. Version strings, part numbers, model numbers and anything else with a meaningful trailing digit have to be quoted, in every version of the spec.
Leading zeros are worse, because the two versions disagree about how. Write zip: 01234 and js-yaml returns the number 1234 — the leading zero is simply gone, because the YAML 1.2 integer pattern is an ordinary decimal. PyYAML returns 668, because under YAML 1.1 a leading zero means octal, and 1234 read in base 8 is 668. So a postal code, a bank sort code or an account number written without quotes is corrupted by both parsers, into two different wrong values. Going the other way, YAML 1.2's own octal notation 0o17 resolves to 15 in js-yaml and stays the string "0o17" in PyYAML, which does not recognise the newer form.
The last of the numeric traps is sexagesimal, and it died with YAML 1.1. That version resolved colon-separated digit groups as base-60 integers, so 12:30:00 became 45000 and 22:22 became 1342. Run it: PyYAML returns exactly those two integers, and js-yaml returns the strings. A crontab-like schedule, a duration, a MAC address fragment or a music timestamp written unquoted in a 1.1 file becomes an integer that has no obvious relationship to what you wrote — 45000 is the number of seconds in twelve and a half hours, which is at least logical, and 1342 is 22 times 60 plus 22, which is not what anyone meant.
Whitespace, forbidden tabs, and the two block scalars
Indentation is structure in YAML, which means whitespace is not cosmetic and a formatter cannot reflow it freely. The specification forbids tab characters in indentation entirely — not discourages, forbids — because a tab has no defined width and the parser would have no way to know how deep you meant to be. Feed a tab-indented list to js-yaml and it stops with a precise complaint: tab characters must not be used in indentation, at line 2 column 1. That error is the single most common YAML failure in an editor that helpfully converts leading spaces.
Multi-line text uses one of two block styles, and the difference is exactly what happens to your line breaks. The literal style, written with a vertical bar, keeps every newline: a two-line block comes back as "line one\nline two\n". The folded style, written with a greater-than sign, joins consecutive lines with a space: the same block comes back as "line one line two\n". Folded style does keep a blank line as a real newline, so a two-paragraph folded block returns "para one line a para one line b\npara two\n" — one paragraph joined, then a genuine break.
On top of the style there is a chomping indicator that decides the trailing newline, and it is the part people forget. The default, written with nothing extra, is clip: exactly one final newline is kept. A minus sign strips it, so the same block comes back as "line one\nline two" with no trailing newline at all. A plus sign keeps every trailing blank line, so a block followed by an empty line returns "line one\nline two\n\n". This matters more than it sounds: a certificate, an SSH key or a shell script embedded in a config file usually needs its final newline, and an embedded token or password usually must not have one. Get it wrong and the failure is a mismatch on a value that looks identical in every diff.
Anchors and aliases: a real feature that is also a bomb
An anchor names a node with an ampersand and an alias refers back to it with an asterisk, and the merge key pulls the keys of a mapping into another one. Together they remove the biggest source of drift in configuration: write your defaults once, then override the two values that differ per environment. Run it and it does exactly what you want — a base block with a timeout of 30 and three retries, merged into dev with the timeout overridden to 5, gives dev a timeout of 5 and retries of 3, while prod keeps 30 and 3.
There is one subtlety worth knowing before you rely on it: an alias does not copy, it shares. Load a document where two list entries alias the same anchor and the two entries are the identical object — strict equality between them is true, and so is strict equality with the original. Mutate one after loading and you have mutated all of them. That is fine for read-only configuration and a genuine trap in code that normalises or patches the loaded tree in place.
The same sharing is what makes the expansion attack possible. Chain anchors so that each level is a list of nine references to the level below, and the size of the logical document is nine to the power of the depth while the file stays tiny. Measured with js-yaml: four levels is 241 bytes of YAML and 54,127 bytes of JSON, a factor of 225; six levels is 349 bytes and 4.38 MB, a factor of 12,563; seven levels is 403 bytes and 39.46 MB, a factor of 97,914. At nine levels the logical node count is 387,420,489. Note where the cost actually lands: js-yaml parsed all of these in under two milliseconds, because the aliases are shared references and the in-memory graph stays small. It was serialising the result that took 363 milliseconds at seven levels. So the defence is not only a parser limit — it is refusing to deep-walk, deep-copy or serialise a tree that came from untrusted YAML, plus a size cap on the input and a limit on alias expansion if your library offers one.
The rule, and what a formatter can and cannot do for you
Quote every string that could be read as something else. In practice that is a short and memorable list: anything that is or contains yes, no, on, off, y, n, true or false; any country code, especially NO; any value with a leading zero; any version or part number with a trailing zero after a decimal point; anything with colons in it, such as a time or a duration; the words null and none and the tilde; and anything that looks like a number but is really an identifier. Single quotes are the safest form because inside them nothing is an escape, so a Windows path or a regular expression comes through untouched.
One thing worth being clear about, because it is a common misconception. This site's YAML formatter does not parse YAML. It normalises the text — it expands tab characters to two spaces, strips trailing whitespace, collapses runs of blank lines, and for minification drops comments and empty lines — and it checks separately for the one hard error, a tab in indentation. It never resolves a scalar, so it cannot turn your NO into false or your 1.10 into 1.1, and it will not reformat your block scalars. That is deliberate: a formatter that round-tripped your file through a parser would silently apply that parser's version of the resolution rules and hand you back a different document.
For the same reason, treat any YAML-to-JSON conversion as a lossy step and inspect the result. Converting is exactly the moment the resolution rules fire, so it is also the cheapest way to find out what your parser really thinks your file says — feed it your configuration, read the JSON, and every quoting bug in this article becomes visible in one pass.
| Written in the file | js-yaml 4.3.0 (YAML 1.2) | PyYAML 6.0.3 (YAML 1.1) | Safe form |
|---|---|---|---|
| no | "no" (string) | False (boolean) | 'no' |
| NO (the ISO code for Norway) | "NO" (string) | False (boolean) | 'NO' |
| yes | "yes" (string) | True (boolean) | 'yes' or true |
| 1.10 (a version number) | 1.1 (number — the zero is gone) | 1.1 (float — the zero is gone) | "1.10" |
| 01234 (a postal code) | 1234 (number — decimal) | 668 (integer — read as octal) | "01234" |
| 12:30:00 (a time of day) | "12:30:00" (string) | 45000 (integer — base 60) | "12:30:00" |
| 0o17 (YAML 1.2 octal notation) | 15 (number) | "0o17" (string — form unknown to 1.1) | Write the decimal value instead |
Frequently asked questions
- Is the Norway problem fixed, and how do I tell which version my parser implements?
- It is fixed in the specification and not necessarily in your program. YAML 1.2 removed yes, no, on and off from the boolean tag of the core schema, so a 1.2 parser leaves them as strings. YAML 1.1 resolved all of them, plus the single letters y and n in its type repository, which is why the ISO country code NO became false. Your file does not declare a version — the %YAML directive exists but essentially nobody writes it — so the behaviour comes entirely from the library. The reliable test takes one minute: load a two-line document containing a key with the plain value no, and print the type of the result. Measured here, js-yaml 4.3.0 returns the string "no", and it does so under all four schemas it ships, from failsafe up to its default. PyYAML 6.0.3 returns the Python False. Both are correct implementations of different specification versions. Note also that implementations differ on the single-letter forms even within 1.1 — PyYAML leaves a bare y and a bare n as strings — so testing beats reading. And regardless of the answer, quoting the value is free and works in every version.
- Why did my version number 1.10 turn into 1.1?
- Because it matched the float pattern, and a float has no memory of trailing zeros. Both parsers agree here — js-yaml returns 1.1 and PyYAML returns 1.1 as a float — so this one is not a spec-version issue and quoting is the only fix. The damage is bigger than a display glitch. Sorting breaks, because as a number 1.1 sits between 1.09 and 1.2 while as a version string 1.10 belongs after 1.9. Equality breaks, because a lookup for the release named 1.10 no longer finds the key. And re-serialising the file writes 1.1 back to disk, so the mistake becomes permanent in your repository and the diff shows a plausible-looking one-character change. The same trap catches any dotted identifier with two components: a chapter number, a firmware revision, a schema version, a decimal product code. Write it as "1.10" in quotes. If you need real ordering semantics, use a three-component semantic version, which contains two dots and therefore cannot match the float pattern at all — 1.10.0 is a string in every parser without quoting, though quoting it anyway costs nothing and removes the need to think about it.
- When do I use the vertical bar and when the greater-than sign?
- Use the vertical bar, the literal style, whenever the line breaks are part of the value: a shell script, a certificate, an SSH key, a SQL statement, an embedded configuration file, an ASCII diagram. Measured, a two-line literal block returns "line one\nline two\n" — every newline preserved, plus one at the end. Use the greater-than sign, the folded style, for prose that you want to wrap in the source file but store as a single line: a long description, a help message, a commit template. The same two-line block folded returns "line one line two\n" — the internal newline became a space. Folded style still honours blank lines as paragraph breaks, so a folded block with an empty line in the middle returns "para one line a para one line b\npara two\n". Then choose the chomping indicator deliberately. Bare keeps exactly one trailing newline, a minus sign removes it entirely, and a plus sign keeps all of them. A PEM certificate needs its final newline, so the bare form is right. A token or a single-line secret must not have one, so use the minus. This is the detail that produces the mysterious signature mismatch or the openssl parse error on a value that looks correct in the file.
- Are anchors and aliases safe to use in production configuration?
- In files you write and review, yes — they are the right tool for shared defaults, and the merge key produces exactly the environment-override pattern most deployments need. Two caveats apply even there. An alias shares the node rather than copying it, verified here by strict equality between two aliased entries, so any code that mutates the loaded tree in place will change every occurrence at once. And the merge key is a YAML 1.1 feature carried forward by convention rather than a part of the 1.2 core schema, so support varies by library — check yours before relying on it. In files that arrive from outside your organisation, treat aliases as a resource-exhaustion vector. Nine-way fan-out over seven levels was 403 bytes of input and 39.46 MB of output here, a factor of 97,914, and nine levels would reach 387,420,489 logical nodes. Where the cost lands is worth knowing precisely: js-yaml parsed every one of those in under two milliseconds, because aliases stay shared references; the 363-millisecond bill arrived when the result was serialised. So the defence is a size cap on the input, an alias-expansion limit if your library exposes one, and a rule against deep-copying or serialising a tree loaded from untrusted YAML.
- Does this site's YAML formatter change the meaning of my values?
- No, because it never parses them. It works at the level of text: it replaces tab characters with two spaces, strips trailing whitespace from each line, collapses runs of three or more blank lines down to one, and in minify mode drops comment lines and empty lines. It also runs one validation, the single hard error the specification defines for whitespace, and reports the line number of any tab found in indentation. Because no scalar is ever resolved, a bare NO stays the two characters NO, 1.10 keeps its trailing zero, and your block scalars come back exactly as written. That is a deliberate design choice: a formatter built on a parser would round-trip your file through that parser's resolution rules and hand back a document with different values, which is precisely the failure this article is about. If you do want to see how your file resolves, convert it to JSON instead — that is the step where resolution happens, and reading the JSON is the fastest audit of your quoting.
Articles you may find interesting
All guides →Related tools
Sources
- YAML.org — YAML Ain't Markup Language (YAML) version 1.2 — core schema, block scalars, anchors and aliases
- YAML.org — YAML 1.1 specification and type repository (the bool, int and sexagesimal resolutions)
- Ecma International — ECMA-404: The JSON Data Interchange Syntax — the subset YAML 1.2 accepts
- nodeca — js-yaml — implementation used by this site; its package metadata declares a YAML 1.2 parser and serializer
- PyYAML — PyYAML documentation — a YAML 1.1 implementation, used here as the 1.1 reference
- OWASP — XML External Entity and billion-laughs style entity-expansion guidance, the same class of attack as YAML alias expansion
Spotted a mistake in this article?