Handling nested configuration in YAML safely

Nested YAML is where two bugs hide: yaml.load will execute arbitrary objects, and YAML’s implicit typing turns on, no, and 3.10 into the wrong Python values. This page parses nested config safely and validates its shape, with particular attention to the implicit-typing trap. It extends YAML & JSON Parsing Strategies.

The code-execution risk of yaml.load is covered in the parent page; this page dwells on the second, subtler bug, because it survives even after you switch to safe_load. YAML’s implicit typing rules — inherited from the YAML 1.1 spec that PyYAML implements — interpret unquoted scalars aggressively: on, off, yes, and no become booleans; 3.10 becomes the float 3.1; a bare null or empty value becomes None; and a country code like no (Norway) or an identifier like NO silently becomes False. None of these is a parse error, so safe_load returns a dict with the wrong values, and the mistake only shows up when the wrong-typed value causes trouble downstream. The fix is two-part: quote ambiguous scalars in the file, and validate every value’s type with a model so an unexpected coercion is caught, not trusted. Nesting compounds the risk, because a deeply nested document has that many more scalars that could be silently retyped, and a mistake buried three levels down is that much harder to spot by eye — which is exactly why a validating model that reports the full field path is so valuable here.

Why does YAML do this at all? The 1.1 spec that PyYAML implements defines a set of implicit resolvers that pattern-match unquoted scalars and assign types: anything matching the boolean pattern (which includes on, off, yes, no, true, false) becomes a bool; anything matching the float or int pattern becomes a number; a bare ~ or empty becomes null. This was meant as a convenience — you can write count: 3 and get an integer — but it over-reaches, treating human-meaningful strings like a Norwegian country code or a semantic version as if they were the numeric or boolean types they merely happen to resemble on the page. Newer YAML 1.2 tightened these rules, but the widely-used PyYAML follows 1.1, so in practice you must assume the aggressive typing is in effect and defend against it. Quoting is the explicit “I mean this as a string” signal, and it is the right habit for any value that is not unambiguously a number or boolean. Version strings, country codes, region names, environment names, feature keys, and anything that could be read as yes/no/on/off all deserve quoting; a plain integer count or a genuine boolean flag does not need it. When in doubt, quote — an unnecessary quote is harmless, whereas a missing one on an ambiguous value is a silent bug.

Problem 1: yaml.load executes objects

# ANTI-PATTERN: a crafted tag in the file runs code during parsing
import yaml
config = yaml.load(open("config.yaml"))   # full loader: remote code execution risk

A document containing !!python/object/apply:os.system runs during load. There is simply never a reason to use it on a file you do not fully control. The rule for this page is simply that every YAML read uses safe_load; the interesting part is what happens after you have safely parsed a nested document, because safe_load fixes the code-execution problem but not the implicit-typing one. So while this first problem has essentially a one-word fix — safe_load — the rest of the page is about the value-level traps that remain once the file is safe to parse and the model that catches them.

safe_load removes execution risk but not implicit typing yaml.load carries a code-execution risk that safe_load removes, but safe_load still applies YAML's implicit typing, so a second class of value bugs remains. yaml.load code execution safe_load no execution risk implicit typing remains on/no/3.10 mis-typed
safe_load closes the code-execution hole but still applies implicit typing, so a value-level bug remains.

Problem 2: implicit typing changes values

# config.yaml — YAML 1.1 implicit typing surprises
feature:
  enabled: on        # parses to True, not the string "on"
  version: 3.10      # parses to float 3.1, dropping the trailing zero
  region: no         # parses to False (Norway country code!)

safe_load still applies YAML’s implicit typing, so version: 3.10 silently becomes 3.1. Quote ambiguous scalars and validate types explicitly. Each of these is a real, documented surprise. enabled: on becomes the boolean True, not the string "on", which is confusing if you expected a string. version: 3.10 becomes the float 3.1 — the trailing zero is dropped, so a version string turns into a number that no longer even reads as “3.10”. And the notorious “Norway problem”: region: no becomes the boolean False, because YAML treats no as a boolean, so a country-code field silently loses its value and becomes a boolean nobody intended. The through-line across all three is that unquoted scalars are interpreted, not preserved, so any field whose value could be mistaken for a boolean, a number, or null needs quoting in the file and a str type in the model.

Three YAML implicit-typing surprises Unquoted on becomes True, 3.10 becomes the float 3.1 dropping the zero, and no becomes False (the Norway problem); quoting preserves the intended string. unquoted → wrong value enabled: on → True (not "on") version: 3.10 → 3.1 (float) region: no → False (Norway!) safe_load still does this quoted → preserved enabled: "on" → "on" version: "3.10" → "3.10" region: "no" → "no" quote + str type in the model
Unquoted scalars are interpreted and mistyped; quoting them and typing the field as str preserves the value.

Secure implementation

# config/nested.py
from pathlib import Path
import yaml
from pydantic import BaseModel, Field


class Feature(BaseModel):
    enabled: bool = False
    version: str                      # forced to string; "3.10" stays "3.10"
    region: str


class AppConfig(BaseModel):
    model_config = {"extra": "forbid"}   # reject unknown nested keys
    feature: Feature
    replicas: int = Field(ge=1, le=100)


def load(path: str = "config.yaml") -> AppConfig:
    raw = yaml.safe_load(Path(path).read_text())   # safe loader only
    return AppConfig.model_validate(raw)           # validate the whole nested tree

safe_load removes the execution risk; nested pydantic models force every value to its declared type, so version stays the string "3.10" and an unexpected key fails with extra="forbid".

The model is the second half of the defence, and it is what makes the implicit-typing problem tractable rather than a game of remembering to quote everything. Declaring version: str on the Feature model means that even if the file has an unquoted 3.10, pydantic will coerce the parsed float back — or, better, you quote it in the file and the str type documents the intent. More importantly, the model catches the cases you did not anticipate: if a field that should be a string comes through as a bool because someone wrote no, and your model declares it str, pydantic either coerces or rejects it, turning a silent value corruption into a visible one at load time. This is the general principle of the settings model applied to file input: the model is the one place the shape and types of the configuration are checked, so every implicit-typing surprise, every missing section, and every typo’d key converges to a single clear error there rather than scattering as obscure failures throughout the code that consumes the config. The nested structure — feature: Feature, replicas: int with a range — means the whole tree is validated in one model_validate call, so a mistake anywhere in the document is caught with its field path.

There is a subtlety about how pydantic and the implicit typing interact that is worth understanding. When YAML parses version: 3.10 to the float 3.1 and your model declares version: str, pydantic will coerce the float 3.1 to the string "3.1" — which is not "3.10". The trailing-zero information is already gone by the time pydantic sees the value, because YAML dropped it during parsing. This is why quoting in the file is not optional for values like versions: the model cannot recover information the parser discarded. The model protects you from type confusion (a bool where you wanted a string), but only quoting protects you from value corruption (a version that loses its trailing zero). Use both, and understand that they defend against slightly different failures — the quote guards the value, the type guards against the wrong shape.

Nested models validate the whole tree and pin types A parsed YAML tree flows into nested pydantic models where version is typed str to resist implicit typing, replicas is a bounded int, and extra=forbid rejects unknown nested keys. parsed tree safe_load dict AppConfig nested + extra=forbid version: str — resists "3.10"→3.1replicas: int, ge=1 le=100unknown key → ValidationError
Typing version as str resists implicit retyping, and the nested model validates the whole tree at once.

Gotchas & version-specific behaviour

  • Quote version-like scalars (version: "3.10") so YAML does not parse them as floats.
  • on/off/yes/no are booleans in YAML 1.1 — quote them if you mean strings.
  • safe_load returns None for an empty file; default to {} before validating.
  • Deep merges of multiple YAML files need an explicit recursive merge — YAML anchors do not merge across files.

Two more gotchas round out the nested-YAML picture. The empty-file case: safe_load returns None (not {}) for an empty or whitespace-only file, so passing that straight into model_validate fails with an unhelpful error; default the result to {} before validating (a simple raw = yaml.safe_load(text) or {}) so an empty file produces a clear “missing required field” error instead of an opaque one about None. And the multi-file merge case: YAML anchors (&name/*name) only work within a single document, so if you split configuration across a base file and an overlay — a common pattern for per-environment config — you cannot use anchors to merge them, because an anchor defined in one file is invisible to another. You need an explicit recursive dict merge in Python before validating the combined result. That merge is itself a place bugs hide (a shallow merge silently drops nested keys), so do it deliberately and validate the merged tree, not each file separately. A recursive merge that walks both dicts and, where both have a dict at the same key, merges them rather than replacing, is the correct shape — a naive base | overlay (or dict.update) is shallow and will replace an entire nested section from the base with the overlay’s, silently discarding keys the overlay did not repeat. This is exactly the kind of bug validation catches: merge, then model_validate the result, and a merge that accidentally dropped a required nested field fails loudly rather than running with a half-configured section.

Four nested-YAML gotchas Quote version-like scalars, quote on/off/yes/no if you mean strings, default an empty file's None to a dict, and merge multiple YAML files with an explicit recursive merge. Quote versionsQuote on/no Empty → NoneMulti-file merge version: "3.10" so it stays a string on/off/yes/no are YAML booleans safe_load returns None — default to {} anchors don't merge across files; merge in Python
Quote ambiguous scalars, default an empty file to a dict, and merge multiple files explicitly before validating.

Production parity checklist

  • Every config file is parsed with safe_load and validated by a nested model.
  • extra="forbid" rejects typo’d nested keys.
  • Ambiguous scalars are quoted to avoid implicit retyping.
  • A CI test loads and validates each config file so a bad edit fails the build.
  • Secrets are referenced from a secret store, never embedded in the file.

The quoting item pairs with the model to defend against implicit typing from both sides: quoting in the file expresses intent to a human reader and stops YAML from retyping the scalar, while the str field in the model catches any that slipped through unquoted. Neither alone is sufficient — you might forget to quote a new value, or the model might not anticipate a field that turns out to be ambiguous — but together they close the gap from both directions — the file signals intent and the model enforces the type, so a value has to slip past both to cause trouble. The CI test that loads and validates every file is what makes the whole thing enforceable: it runs the same safe_load plus model_validate, so a newly-added unquoted version: 3.20 that pydantic can no longer coerce, or a typo’d nested key, fails the build rather than the deploy. Because nested YAML is edited by hand and its structure is easy to break in ways that only bite when a specific value is read, this CI check is especially valuable here — it turns “someone mis-indented a section” from a production surprise into a failed pull request, caught the moment the file changed.

Nested-YAML production-parity checklist Parse every file with safe_load and a nested model, forbid typo'd keys, quote ambiguous scalars, add a CI validation test, and reference secrets from a store. Every config file is parsed with safe_load and validated by a nested model extra="forbid" rejects typo'd nested keys Ambiguous scalars are quoted to avoid implicit retyping A CI test loads and validates each config file so a bad edit fails the build Secrets are referenced from a secret store, never embedded in the file
Safe loader, nested model, forbid extras, quoted scalars, CI validation, and no secrets in the file.

Key takeaways

safe_load plus nested pydantic models with extra="forbid" makes nested YAML both safe to parse and correct once parsed. Nested YAML carries two distinct bugs, and they need two distinct defences. The code-execution bug is closed by safe_load — a one-word fix that must be applied everywhere. The implicit-typing bug survives safe_load, because YAML still interprets unquoted scalars: on becomes a boolean, 3.10 becomes the float 3.1, and no becomes False (the Norway problem). That one is closed by quoting ambiguous scalars in the file and validating every value’s type with a nested model.

The model does the heavy lifting for the second bug because it turns implicit-typing surprises into explicit errors: a field typed str either coerces a mistyped value back or rejects it, and a nested structure validates the whole tree at once with precise field paths that point straight at the offending value. Add extra="forbid" to catch typo’d nested keys, default an empty file’s None to {}, merge multiple files explicitly rather than relying on anchors, and back it all with a CI test — and nested YAML becomes as trustworthy as any other validated configuration source, with none of the silent value corruption that makes unvalidated YAML a liability. For format trade-offs, see TOML vs YAML vs JSON for Python Config.