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'
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"
pytest -q test_yaml_norway.py
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))
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 "."))
python scan_yaml_implicit_bools.py ./config
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
- Reproduce with
reprandtypeon the loaded object before blaming storage, HTTP, or pandas. - Add one pytest that asserts both keys and value types for every identifier-like YAML field.
- Quote country codes, province codes, and short names at the source instead of casting later.
- Run the directory scanner in CI on the rendered files, not only on the templates.
- 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)