~/blog

In YAML, no is false — and NO is Norway

published

#yaml#config#python

TL;DR

YAML 1.1 resolves no, off, yes and on to booleans. YAML 1.2 does not. Two parsers, same file, different data:

a: no
b: NO
c: yes
d: off

PyYAML 6.0.3 (yaml.safe_load) returns {'a': False, 'b': False, 'c': True, 'd': False}. js-yaml 4.1.1 (yaml.load) returns {a: 'no', b: 'NO', c: 'yes', d: 'off'}. Quote every value you meant as a string.

The problem

Someone adds a country to a config file. Norway’s ISO 3166-1 code is NO.

country: NO
>>> import yaml
>>> yaml.safe_load('country: NO')
{'country': False}

Nothing threw. Norway is now False, and downstream you get a TypeError about a bool where a string was expected — three call frames away from the file that caused it. This is the “Norway problem”, and it is the most quoted example because the failing value looks nothing like a boolean to a human reader.

It gets worse when the coerced token is a key rather than a value. GitHub Actions workflows start with on::

>>> yaml.safe_load('on: push')
{True: 'push'}

The key is the boolean True, not the string 'on'. Any code doing cfg['on'] raises KeyError: 'on' while the data is sitting right there under a different key.

Why it happens

YAML 1.1 shipped a type repository where !!bool matches a wide set of spellings — y, yes, no, on, off, true, false and their case variants. YAML 1.2 narrowed the core schema to true and false only (plus True/TRUE-style casings). Both specs are still in active use, because the choice lives in your parser, not in your file.

ParserYAML versionnoon1.1022:22
PyYAML 6.0.3 (safe_load)1.1FalseTrue1.1 (float)1342 (int)
js-yaml 4.1.1 (load)1.2 core'no''on''1.10''22:22'

The last two columns are the same bug wearing different clothes. 1.10 parses as the float 1.1, so a pinned version silently becomes a different version. 22:22 parses as sexagesimal — base 60 — giving 1342, which is how a MAC address or a duration turns into an integer.

One nuance worth knowing before you go quoting everything in sight: PyYAML does not coerce single-letter y and n, even though YAML 1.1 lists them.

>>> yaml.safe_load('a: y\nb: n')
{'a': 'y', 'b': 'n'}

So “YAML 1.1 parser” is not a single behaviour either. The resolver in front of you is the authority, not the spec it claims to follow.

What to do

Quote the value. This is the whole fix, and it works on every parser and every version:

country: "NO"
version: "1.10"
mac_suffix: "22:22"

Dumpers already know this. PyYAML quotes the string back out when it would otherwise re-read as a boolean:

>>> print(yaml.safe_dump({'country': 'no'}))
country: 'no'

That asymmetry is the tell: the library considers the bare form ambiguous enough to protect on write, but not on read.

Know which parser reads the file. The same repo often has several — a Python service on PyYAML, a Node build step on js-yaml, a CI runner on something else entirely. A config that round-trips fine in one can flip meaning in another. If you are migrating a codebase from js-yaml v3 to v4, that is a 1.1 → 1.2 change, and previously-boolean values become strings.

Round-trip suspicious files through JSON. JSON has exactly two boolean literals and no implicit typing, so converting shows you what your parser actually built. A false where you wrote NO is unambiguous in a way the YAML source is not — that is what the JSON ↔ YAML converter is for, and it runs in your browser, so a config with secrets in it never leaves the tab.

For new formats, prefer explicit types. If you control the schema, validate after load: assert that country is a str before anything uses it. Type coercion bugs that surface at load time cost minutes; the same bug surfacing three layers deep costs an afternoon.

Caveats

References