DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: The YAML Loaded Cleanly. Country Code NO Was False.

I spent two nights staring at a country filter that silently dropped Norway from a generated report. The YAML file looked boring, the loader returned a dict, and every test that checked keys still passed. Have you ever trusted a green assertion that never looked at the actual value types underneath? I certainly did, and that misplaced confidence sent me down several noisy and completely wrong paths.

The symptom felt almost too small to take seriously during that first distracted afternoon debugging pass. A helper loaded regions.yaml, built a set of country codes, and skipped anything missing from that set. Norway's code is NO, which I had typed with the same muscle memory I use for US and DE. Why would that one mapping vanish while the neighboring codes survived every export I later inspected?

What I tried for the first day

I printed the loaded mapping and stared at it as if the types would politely announce themselves in the terminal. Python's default print showed False sitting where I expected a quoted country string to appear. Did I mention I had three copies of regions.yaml across fixtures, package data, and a local override folder? I grepped for NO and found it in the source file every time, which felt like proof.

I then logged the membership test and confirmed the lookup really used the expression 'NO' in codes at runtime. The set contained False, True, and a handful of ordinary strings, which should have been an immediate siren. Instead I hunted for a pandas read_csv call that might have coerced a column, because that class of bug has bitten me before. The CSV path was innocent, the query was innocent, and the YAML loader was not.

Commands that would have ended the detour

Here are the commands I wish I had run before blaming the database join:

python -c "import yaml; print(repr(yaml.safe_load(open('regions.yaml'))))"
python -c "import yaml; print(yaml.safe_load('NO: nordic'))"
rg -n 'NO:' -g '*.yaml'
Enter fullscreen mode Exit fullscreen mode

The second command is the one that finally made me feel foolish about the whole hunt. Have you noticed how easy it is to skip repr and type when the keys still look right on disk? I had been reading the file, not the object graph the process actually used after parse.

What actually broke

PyYAML's default safe_load still follows YAML 1.1 implicit typing for a cluster of familiar looking words. Values like NO, no, off, and ON become booleans before your application code ever sees a string. Country codes, feature flags spelled on, and even a lonely n can disappear into True or False without raising. I was not hitting a corrupt file, a wrong working directory, or a stealthy second parser in another process.

The load succeeded, which is exactly why the tests stayed green for so long overnight. Key existence checks do not care that the value changed type during parse. My fixture even round-tripped through dump and load in a way that hid the original token. Once False is in the dict, dumping it writes false, and the original NO disappears from any generated snapshot you later review.

The smallest repro I should have written on hour one

I rebuilt the failure as a tiny pytest file so I could stop arguing with scattered log lines. If you want to follow along, save the test next to a sample fixture and run pytest directly on that file. The point is not clever mocking. The point is to pin the type of every identifier-like field before the report code ever runs.

Pytest that fails on purpose

# test_yaml_norway.py
import io
import yaml

SAMPLE = """
regions:
  US: north_america
  DE: europe
  NO: europe
  ON: ontario
flags:
  feature_x: on
  feature_y: "on"
"""

def test_identifier_fields_stay_strings():
    data = yaml.safe_load(io.StringIO(SAMPLE))
    assert list(data["regions"].keys()) == ["US", "DE", "NO", "ON"]
    assert data["regions"]["NO"] == "europe"
    assert data["flags"]["feature_x"] == "on"
    assert data["flags"]["feature_y"] == "on"
Enter fullscreen mode Exit fullscreen mode
pytest -q test_yaml_norway.py
Enter fullscreen mode Exit fullscreen mode

Then load the same snippet in isolation so the types are impossible to ignore:

import yaml
from pprint import pprint

raw = """
regions: {US: north_america, DE: europe, NO: europe, ON: ontario}
flags: {feature_x: on, feature_y: "on"}
"""
pprint(yaml.safe_load(raw))
Enter fullscreen mode Exit fullscreen mode

You should see keys False and True, plus feature_x set to True while the quoted on remains a string. That quoting difference is the entire game once you stop staring at the source file. Unquoted YAML 1.1 scalars get a second chance at becoming bool, int, float, or timestamp during load. Quoted scalars stay strings, which is the fix I applied to every country code in the checked-in file.

A scanner I wish I had before the second night

Hand-quoting one file does not protect the next overlay, the next generated snippet, or the next copied values file. I wanted a boring checker that walks a directory, reads each mapping as text, and flags implicit YAML 1.1 booleans. The script below is the artifact I now keep beside the configs, and it is deliberately picky about unquoted scalars only.

Scanner script

# scan_yaml_implicit_bools.py
from __future__ import annotations

import pathlib
import sys

IMPLICIT_BOOLS = {
    "y", "Y", "yes", "Yes", "YES",
    "n", "N", "no", "No", "NO",
    "true", "True", "TRUE",
    "false", "False", "FALSE",
    "on", "On", "ON",
    "off", "Off", "OFF",
}


def plain_scalar(line: str) -> tuple[str, str] | None:
    stripped = line.split("#", 1)[0].rstrip()
    if ":" not in stripped:
        return None
    key, _, val = stripped.partition(":")
    val = val.strip()
    if not val or val[0] in {'"', "'", "{", "[", "|", ">"}:
        return None
    return key.strip(), val


def scan_file(path: pathlib.Path) -> list[str]:
    hits: list[str] = []
    for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        parsed = plain_scalar(line)
        if parsed is None:
            continue
        key, val = parsed
        if val in IMPLICIT_BOOLS:
            hits.append(
                f"{path}:{line_no}: unquoted {val!r} under {key!r} becomes a YAML 1.1 boolean"
            )
    return hits


def main(root: str) -> int:
    failures: list[str] = []
    for path in pathlib.Path(root).rglob("*"):
        if path.suffix.lower() not in {".yml", ".yaml"}:
            continue
        failures.extend(scan_file(path))
    for line in failures:
        print(line)
    return 1 if failures else 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1 else "."))
Enter fullscreen mode Exit fullscreen mode
python scan_yaml_implicit_bools.py ./config
Enter fullscreen mode Exit fullscreen mode

Is the line parser complete across the whole YAML spec? No, and I do not pretend it understands flow style, tags, or merge keys. It does catch the Norway case, the ON province case, and the unquoted on flag in files I actually ship. That was enough to turn a two-day mystery into a pre-commit check I can explain without waving my hands.

I did not want to run that walk against a laptop copy that already mixed secrets, generated values, and half-edited buffers. After the local repro was solid, I used MonkeyCode's free model access and free server option to draft and run the scanner. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The useful part remains the pytest file and the scanner, which anyone can run locally. The remote box was only a clean place to execute them without fighting my dirty working tree.

Decision table I now keep in the README

Input scalar Quoted? Typical PyYAML safe_load result Safer spelling
NO no False "NO"
ON no True "ON"
off no False "off", or false if you meant a bool
"on" yes "on" keep the quotes
true no True fine when you meant a bool
n no False "n" if it is a name

I treat this table as a code review cheat sheet rather than a complete YAML specification for every edge. If a value is a real boolean, I now write true or false and leave it unquoted on purpose. If a value is an identifier, I quote it, even when it looks harmless in today's file. Would you rather argue with a loader or with a one-character pair of quotes?

What I would repeat

  1. Reproduce with repr and type on the loaded object before blaming storage, HTTP, or pandas.
  2. Add one pytest that asserts both keys and value types for every identifier-like YAML field.
  3. Quote country codes, province codes, and short names at the source instead of casting later.
  4. Run the directory scanner in CI on the rendered files, not only on the templates.
  5. Keep secrets out of any shared or free remote environment, including throwaway servers.

Would I try to fix this by switching loaders mid-incident again after the first scare? Not unless I can prove every producer and consumer moved to YAML 1.2 together. Changing the parser underneath a pile of Helm values is a second outage waiting for a quieter week. Quoting the identifiers is local, reviewable, and boring, which is what I wanted after two long nights.

Limitations, and who should skip this

This approach will not save you if a custom constructor already rewrites scalars, or if the document is JSON-in-YAML with a different parser path. The scanner misses inline flow mappings, multiline strings, and values hidden behind aliases. It can also nag you about an unquoted false that really is a boolean, which is a style choice, not a crash. I still run the pytest even when the scanner stays quiet, because tests see the object graph and the scanner only sees text.

Skip this workflow if your configs are already TOML or JSON, because those formats do not perform the YAML 1.1 Norway trick. Skip the remote scan if the files contain credentials, customer dumps, or production overlays you should not copy off-laptop. And please skip any urge to cast every YAML value through str() as a blanket fix, because then real booleans become the strings 'True' and 'False', which is a second mess.

I still like YAML for comments and for human-edited overlays, but I no longer let identifier fields ride unquoted. The report is back to including Norway, the tests check types, and the scanner fails the build when someone adds NO again. If you already have an equivalent check in CI, keep that one and just steal the pytest assertion.

Top comments (0)