YAML vs JSON: The Gotchas That Silently Corrupt Your Config (Norway Problem and More)

The bug that gets you is never a syntax error. A malformed YAML file fails loudly at parse time, and you fix it in ten seconds. The expensive bug is the file that parses cleanly and means something other than what you typed. Your country field is now a boolean, your file mode lost its leading zero, and your config validator passed because, structurally, nothing is wrong.

This is YAML's defining trait: it tries to guess the type of every unquoted value. When the guess matches your intent, it feels effortless. When it doesn't, it fails silently into production. JSON has its own quiet corruption story on the numeric side, and it bites a different class of system.

Here is the part that surprises people in 2026: most of these traps were officially fixed in the YAML 1.2 spec back in 2009. They keep biting because the parser sitting in your most-used pipeline probably still implements YAML 1.1.

The root cause: YAML guesses types, and version 1.1 guesses aggressively

JSON has six types and almost no ambiguity. A value is a string only if it is in quotes, a number only if it looks like one, and true is the only thing that means true. There is nothing to guess.

YAML inverts that. Almost everything is written bare, and the parser applies a resolution chain to decide what each scalar is. The chain in YAML 1.1 is wide. It recognizes a sprawling set of boolean spellings, several integer bases, floats, timestamps, and nulls, all from unquoted text. YAML 1.2, released in 2009, deliberately narrowed that chain to bring the core schema closer to JSON.

So the real question for any config bug is not "is my YAML valid" but "which spec version is my parser enforcing." And the answer is rarely the modern one. PyYAML, the default in most Python tooling, still implements YAML 1.1. If your CI, your Ansible playbooks, or your app's config loader runs through PyYAML, you are on 1.1 whether you meant to be or not. That single fact is why a class of bug fixed 17 years ago still lands in pull requests today.

The Norway problem: when NO becomes false

This is the famous one, and it is worth understanding exactly, because the usual one-line summary undersells how many values it covers.

You have a list of country codes:

countries:
  - GB
  - FR
  - NO
  - SE

Under a YAML 1.1 parser, that list is ["GB", "FR", false, "SE"]. Norway's ISO code, NO, matches the 1.1 boolean rule and resolves to the boolean false. The other codes happen not to collide with a keyword, so they survive as strings. Now your country lookup throws a type error, or worse, silently treats Norway as a falsy flag and skips it.

The trap is wider than NO. PyYAML treats all of these unquoted tokens, in the capitalizations shown, as booleans:

  • yes, Yes, YES, no, No, NO
  • true, True, TRUE, false, False, FALSE
  • on, On, ON, off, Off, OFF

One nuance the short summaries miss: the YAML 1.1 spec also lists the bare single letters y and n as booleans, but PyYAML's resolver does not match them. So y and n stay strings while yes, no, on, and off coerce. Do not rely on that asymmetry; rely on quoting.

That on/off pair is a quiet killer in feature-flag config. A key written debug: off does not store the string "off" under PyYAML. It stores boolean false. A teammate reading the file sees a self-documenting toggle. The program sees a bool. They are not the same value, and only one of them is what you intended.

YAML 1.2 fixes this by restricting the boolean set to exactly true and false. Under a 1.2 parser, NO and off stay strings. The fix exists. Your parser may not have it.

Numbers: octal, leading zeros, and version strings that become floats

Booleans get the headlines. The numeric coercions are sneakier because the corrupted value still looks like a plausible number.

Leading-zero octal. In YAML 1.1, an all-octal-digit value with a leading zero is read as octal. Write a Unix file mode the way every chmod tutorial shows it:

mode: 0755

A 1.1 parser reads that as octal and hands your program the decimal integer 493. Pass 493 to an API expecting a mode and you have granted permissions nobody asked for. Any value made only of digits 0-7 with a leading zero behaves this way: 02110 silently becomes 1096. YAML 1.2 dropped bare-leading-zero octal and requires an explicit 0o prefix, so 0755 there is just the decimal number 755. Two parsers, two different integers, same source file.

Not every leading zero corrupts, which is the trap. 02139, a real US zip code, contains a 9, so it is not valid octal and PyYAML keeps it as the string "02139". Whether a value survives depends on whether its digits happen to be 0-7. That is not a rule worth memorizing. The safe model: if a value is an identifier that happens to be all digits, quote it. Zip codes, account numbers, and dialing codes are data, not arithmetic.

Version strings that are not strings. This one fools careful people:

version: 1.10

You meant the string "1.10". YAML sees a float, parses it, and the trailing zero evaporates. Your value is now 1.1. Your dependency pin, your API version header, your migration tag, all silently off by a patch version, and the diff that introduced it looks innocent.

Dates, nulls, and the empty-string trap

Implicit dates. YAML 1.1 resolves an ISO-8601-looking bareword into a timestamp object:

release: 2026-06-22

Under PyYAML that release value is a Python datetime.date, not the string "2026-06-22". If your code does string operations on it, or serializes it back out expecting the original text, you get a type error or a reformatted value. Often you wanted the literal string, especially for version tags shaped like dates.

Null spellings. Empty values and the tokens null, Null, NULL, and a bare ~ all resolve to null. The worst variant is the accidental empty value:

retries:
name: worker

That retries key has no value on its line, so it is null, not 0 and not "". Downstream code doing retries + 1 blows up, and the file looks fine to a human skim.

safe_load: a security hole, not just a bug

Everything above corrupts data. This one executes it.

PyYAML's yaml.load() historically supported full YAML tags, including tags that instantiate arbitrary Python objects. A crafted document can carry a payload like !!python/object/apply:os.system and turn config parsing into remote code execution. If any YAML you load comes from user uploads, webhooks, or a multi-tenant source, an unsafe load() on it is a vulnerability.

The fix is yaml.safe_load(), which restricts resolution to basic types: maps, lists, strings, numbers, booleans, nulls. Since PyYAML 5.1, calling load() without an explicit Loader emits a deprecation warning, and the default loader was changed to one that no longer instantiates arbitrary objects. Do not rely on that default; use safe_load for anything you did not write yourself, and by default everywhere. One caveat: safe_load fixes code execution, but it is still a YAML 1.1 parser, so it does not change the type coercion above. It does not save you from the Norway problem.

The JSON side: silent integer corruption above 2^53

JSON avoids YAML's type guessing, but it has its own quiet failure, and there is no quote you can add to dodge it.

JavaScript numbers are IEEE-754 doubles. They represent integers exactly only up to Number.MAX_SAFE_INTEGER, which is 9007199254740991, or 253-1. Past that, precision degrades silently. JSON.parse in the browser and in Node has no special handling for big integers; it just produces a double.

JSON.parse('{"id": 9007199254740993}').id
// => 9007199254740992  (your 3 became a 2)

9007199254740993 === 9007199254740992
// => true

The value comes back wrong, and the equality check that should catch it also returns the wrong answer, because both literals collapse to the same double. There is no exception. The data is just incorrect.

This destroys 64-bit IDs. Twitter/X and Discord snowflake IDs, database bigint primary keys, and most distributed-system identifiers exceed 253. The fix is to transport them as JSON strings, not numbers, end to end, so the parser never touches them as floats. If you control the API, serialize big IDs as strings. If you do not, you may need a parser that supports BigInt. Either way, the only durable fix is to stop putting integers above 253 in JSON number positions.

Use the converter to see what your YAML actually resolved to

The hardest thing about every trap above is that you cannot see the coercion in the source file. The YAML looks like what you typed. The damage only appears as a type after parsing, deep in your program.

So make the type visible. Paste your YAML into the YAML to JSON converter and read the JSON output. JSON has no ambiguity, so the output is a ground-truth statement of what each scalar resolved to. If debug: off comes out as "off" with quotes, it is a string. If it comes out as false, it is a bool.

One nuance, and it is a feature: the codeswap converter parses with a YAML 1.2 engine (js-yaml). So country: NO comes out as the string "NO", exactly as 1.2 intends. That is the diagnostic. If the tool shows a clean string but your PyYAML-based pipeline treats the same field as false, you have proven the bug lives in your 1.1 parser, not in your file.

It runs entirely in your browser, so you can paste config containing secrets without it leaving your machine. It also resolves anchors and aliases and flattens multi-document --- streams into a JSON array, which helps when a Kubernetes manifest's reuse blocks obscure the final merged values.

How not to get bitten

A short checklist that covers the whole space:

  • Quote any scalar that is really a string. Country codes, zip codes, version numbers, file modes, anything that could be misread as a bool, number, or date. Quotes are free; a silent type change is not.
  • Know your parser's version. In Python you are almost certainly on YAML 1.1 via PyYAML, so assume the aggressive coercion rules apply. Go's gopkg.in/yaml.v3 and JavaScript's js-yaml v4 default to 1.2 and are tamer, but tamer is not safe, so still quote your strings.
  • Always safe_load. Never run an unsafe yaml.load() on untrusted input, and prefer safe_load even for your own files.
  • Transport big integers as strings. Any ID above 253 goes in JSON as a string, both directions. Do not rely on the consumer to use a BigInt-aware parser.
  • Convert, then inspect. Before you trust a config file, run it through the converter and read the JSON. It turns invisible coercion into a value you can look at. More JSON utilities live under the JSON tools collection.

The throughline: YAML optimizes for the human writing the file, JSON for the machine parsing it, and the gap between those two is exactly where config silently corrupts. You close the gap by being explicit, and by checking the machine's interpretation instead of trusting your own.

Use the tool

Skip the manual work. The companion tool runs this in your browser, with nothing uploaded.

YAML to JSON converter

Frequently asked questions

Why does YAML turn NO into false?

Because YAML 1.1 treats a list of unquoted tokens as booleans, including no, NO, off, and yes. Norway's country code NO matches the boolean rule and resolves to false. PyYAML implements YAML 1.1, so it still does this in 2026. YAML 1.2 restricts booleans to only true and false, which is why a 1.2 parser leaves NO as a string. The fix in any version is to quote the value: country: "NO".

Does the codeswap YAML to JSON converter reproduce the Norway problem?

No, and that is deliberate. The converter uses a YAML 1.2 engine (js-yaml), so country: NO comes out as the string "NO", not false. That makes it a useful diagnostic: if the tool shows a clean string but your Python pipeline treats the same field as false, you have confirmed the bug is in your YAML 1.1 parser (PyYAML), not in the file itself.

What is the difference between yaml.load and yaml.safe_load?

yaml.load() in older PyYAML could instantiate arbitrary Python objects via YAML tags, which means a crafted document could achieve remote code execution. yaml.safe_load() restricts parsing to basic types (maps, lists, strings, numbers, booleans, nulls). Use safe_load for any input you did not write yourself, and ideally everywhere. Since PyYAML 5.1, calling load() without an explicit Loader argument emits a deprecation warning for this reason.

Why does JSON.parse change large integers?

JavaScript numbers are IEEE-754 doubles, which only represent integers exactly up to 2^53-1 (9007199254740991). JSON.parse produces a double for any number, so an integer above that limit loses precision silently. JSON.parse('{"id":9007199254740993}') returns 9007199254740992. The fix is to transport 64-bit IDs (snowflake IDs, database bigints) as JSON strings, not numbers.

Is the Norway problem fixed in YAML 1.2?

Yes. YAML 1.2 (released 2009) narrowed the core schema to recognize only true and false as booleans, so tokens like NO, off, and yes stay strings. The problem persists because many widely-used parsers, most notably PyYAML in the Python ecosystem, still implement YAML 1.1. Knowing which spec version your parser enforces matters more than knowing the spec is fixed.

How do I check what type my YAML value actually became?

Paste the YAML into a YAML to JSON converter and read the JSON output. JSON is unambiguous, so the output tells you exactly what each scalar resolved to: quotes mean string, no quotes on true/false means boolean, and so on. This turns an invisible type coercion into a value you can see, instead of debugging it from a stack trace deep in your program.

Related tools