TOML vs YAML vs JSON for Python config

Three formats dominate Python configuration files today, and each fails differently in its own way: JSON has no comments, YAML has implicit-typing footguns and an unsafe loader, and TOML is verbose for deep nesting. This page picks the right one per job and validates all three the same way. It extends YAML & JSON Parsing Strategies.

The choice is easier once you see it as matching a format’s strengths to a use, not ranking the formats absolutely. JSON is a data-interchange format first: strict, unambiguous, universal — excellent as an API payload or a machine-generated artifact, but its lack of comments and intolerance of trailing commas make it a chore to hand-edit. TOML is a configuration format by design: comments, a clear mostly-flat structure, explicit typing, and the format the Python ecosystem standardised on for pyproject.toml — ideal for hand-edited application settings. YAML is the most expressive: anchors for de-duplication and comfortable deep nesting — worth reaching for when the structure genuinely demands it, but at the cost of implicit-typing surprises and an unsafe default loader you must remember to avoid.

The reassuring part is that the decision is low-stakes, because whichever format you pick, the parsed result flows into the same validated model. So the choice affects only the editing experience — comments, whitespace, tooling — never the correctness of the loaded configuration. The rest of this page lays out the trade-offs and shows the one loader that reads all three into a single validated model.

It is worth stating why this decision comes up so often and generates so much debate. Configuration files sit at the intersection of two audiences — humans who edit them and machines that read them — and the three formats optimise for different points on that spectrum. JSON leans hard toward the machine; TOML sits in the middle, readable and writable by humans while still strict; YAML leans toward human expressiveness at the cost of predictability. There is no universally best answer because the right point on the spectrum depends on who edits the file and how complex its structure is. What removes the anxiety from the choice is the validation layer: whichever format you pick, the model is the backstop that guarantees the loaded configuration is correct, so a suboptimal format choice costs you some editing friction, never a production bug, and can be revisited later without consequence.

Problem 1: YAML implicit typing

# ANTI-PATTERN: values silently retyped
version: 3.10     # -> float 3.1
enabled: on       # -> True
country: no       # -> False

YAML 1.1 reinterprets these scalars; quote them or validate types explicitly. This is YAML’s headline weakness for configuration: version: 3.10 becomes the float 3.1, enabled: on becomes True, and country: no becomes False (the Norway problem). None is a parse error, so the file loads with silently wrong values. It is not a reason to never use YAML, but it is a real tax — every ambiguous scalar needs quoting in the file and a typed field in the model — and it is the main thing that pushes hand-edited configuration toward TOML, which has none of this behaviour.

YAML implicit typing retypes unquoted scalars In YAML, version 3.10 becomes the float 3.1, enabled on becomes True, and country no becomes False, none of which is a parse error. version: 3.10 → 3.1 (float, zero lost) enabled: on → True (not "on") country: no → False (Norway!) unquoted scalars are silently retyped — no parse error
YAML's implicit typing turns unquoted version, on, and no into wrong values with no error — the tax of using YAML.

Problem 2: JSON with no comments and trailing-comma errors

{
  "port": 8080,
  "debug": false,
}

That trailing comma is a parse error, and JSON has no way to document a field inline. These are JSON’s weaknesses as a hand-edited config file, not as a data format — where its strictness is a virtue. A config file is edited by people, and people want to leave a comment explaining a non-obvious value and to add a trailing comma without breaking the file; JSON allows neither. It is a superb wire format precisely because it is strict and unambiguous, and a frustrating config file for exactly the same reason. That is why, for hand-edited settings, TOML and YAML — both of which support comments and are forgiving about trailing commas — are the better fit, and JSON is best reserved for machine-generated config where its strictness helps and its lack of comments does not hurt. A useful heuristic: if a human will open the file to change a value, do not make them fight JSON’s punctuation; if only a program writes and reads it, JSON’s rigidity is exactly the property you want.

JSON versus TOML and YAML for hand-edited config JSON forbids comments and trailing commas and is verbose, making it a great wire format but a poor hand-edited config; TOML and YAML support comments and are forgiving, better for hand editing. JSON ✗ no comments allowed ✗ trailing comma is an error ✗ verbose quoting ✓ great as a wire format TOML / YAML ✓ comments supported ✓ forgiving of trailing commas ✓ less punctuation ✓ great for hand editing JSON: fine data-interchange format, poor hand-edited config file
JSON's strictness makes it a great wire format and a poor hand-edited config; TOML and YAML suit human editing.

Secure implementation

# config/format_loader.py
import json
import tomllib                          # stdlib, Python 3.11+
from pathlib import Path
import yaml
from pydantic import BaseModel

class Config(BaseModel):
    model_config = {"extra": "forbid"}
    port: int = 8080
    debug: bool = False

def load(path: Path) -> Config:
    text = path.read_text()
    match path.suffix:
        case ".toml":
            raw = tomllib.loads(text)        # typed, comments, no surprises
        case ".json":
            raw = json.loads(text)           # machine-friendly, strict
        case ".yaml" | ".yml":
            raw = yaml.safe_load(text)       # safe_load ONLY
        case _:
            raise ValueError(f"unsupported: {path.suffix}")
    return Config.model_validate(raw)        # validate regardless of format

Whatever the format, the parsed dict is validated by the same pydantic model — so the format is a readability choice, not a correctness risk.

The load function makes the “format is ergonomic, model is correctness” split concrete. A match on the file suffix picks the safe loader — tomllib.loads, json.loads, or yaml.safe_load — and rejects unknown formats, so the sourcing is format-aware and always safe. Then a single Config.model_validate(raw) runs regardless of which loader produced the dict, so the validation is format-agnostic. The consequence is that you can mix formats in one codebase, or migrate a file from JSON to TOML, without touching the model or any downstream code: the model validates a dict, and it does not care where the dict came from. This is the same single-boundary discipline that governs every configuration source on this site, applied across file formats.

The match statement on the suffix is a small but meaningful design choice. It makes the mapping from extension to loader explicit and exhaustive — every supported format has a case, and the case _ default branch rejects anything unexpected with a clear error rather than falling through to a wrong loader. This matters because picking the loader by extension is the one place a format mistake could sneak in: load a .yaml file with json.loads and you get a confusing parse error; load a .json with the YAML loader and you might get subtly different typing, since YAML is a superset of JSON but applies its own implicit rules. Pinning each extension to its correct loader in one obvious place eliminates that class of mistake, and the default case ensures an unrecognised extension fails loudly rather than silently doing the wrong thing.

Three formats, three safe loaders, one validating model A TOML, JSON, or YAML file is read by its safe loader into a dict, and one Config model validates the dict regardless of format. .toml → tomllib.loads.json → json.loads.yaml → yaml.safe_load plain dict Config.model_validate format-agnostic
Each format uses its safe loader; one model validates the resulting dict, so the format never affects correctness.

Comparison

Format Comments Typing Loader Best for
TOML yes explicit tomllib hand-edited app config (pyproject.toml)
YAML yes implicit (risky) yaml.safe_load deep nesting, anchors
JSON no explicit json.loads machine-generated config / APIs

Read the table across, and a clear default falls out. For a config file a human maintains, TOML wins: it has comments, explicit typing (no implicit surprises), and is the format Python tooling already speaks. Reach for YAML only when you genuinely need its expressiveness — anchors or deep nesting that TOML makes clumsy — and pay the safe_load-plus-quoting tax for it deliberately. Use JSON when the file is produced by a machine — an export, a generated manifest, an API response you cache — where its strictness is an asset and no human edits it. The typing column is the sharpest discriminator: TOML and JSON are explicit (a string is a string), while YAML’s implicit typing is the one genuine footgun among the three, which is why it drops to third choice for hand-edited files despite being the most powerful. There is a practical corollary: TOML’s flat, table-based structure is genuinely awkward once configuration nests several levels deep, with repeated [section.subsection] headers, so the honest guidance is TOML for shallow-to-moderate hand-edited config and YAML for genuinely deep hierarchies — accepting the safe-load-and-quote discipline as the price of YAML’s nesting. If your config is both deeply nested and frequently hand-edited, that tension is real, and the resolution is usually to keep the file safe and validated and to lean on the model’s precise error paths, which name the exact field, to make YAML’s footguns survivable in practice.

Choosing a config format by its default use TOML is the default for hand-edited application config, YAML for deep nesting and anchors, and JSON for machine-generated config, all validated by one model. TOML hand-edited app config comments, explicit types YAML deep nesting, anchors safe_load + quote scalars JSON machine-generated strict, no comments all validated by one model
TOML by default for hand-edited config, YAML for nesting, JSON for machine output — one model validates all three.

Gotchas & version-specific behaviour

  • tomllib is read-only and stdlib in 3.11+; on 3.10 use tomli. To write TOML, use tomli-w.
  • YAML must use safe_load; quote ambiguous scalars like "3.10" and "on".
  • JSON cannot carry comments — keep a sibling docs file or switch to TOML.
  • Validate every format with extra="forbid" so typo’d keys fail.

The TOML gotcha is a version and direction one: tomllib is read-only and standard from Python 3.11, so on 3.10 you install the tomli backport, and to write TOML you need tomli-w — the stdlib only reads. The YAML gotcha is the one carried from the parent page: always safe_load, and quote ambiguous scalars. The JSON gotcha is the comments limitation — if you need to document a JSON config, keep a sibling docs file or, better, switch to TOML, which supports comments natively. And the last gotcha applies to all three: validate with extra="forbid" so a typo’d key in any format is rejected rather than silently ignored. These are per-format quirks, but they all sit underneath the same rule — pick the safe loader, then validate. The tomllib read-only detail catches people who assume a stdlib TOML parser also writes: it does not, by design, because TOML round-tripping (preserving comments and formatting) is hard, so writing is left to third-party libraries. In practice this rarely matters for configuration, which you read far more than you write programmatically, but it is worth knowing before you reach for a tomllib.dump that simply does not exist in the standard library.

Four format gotchas tomllib is read-only and 3.11+, YAML must use safe_load with quoted scalars, JSON has no comments, and every format should validate with extra=forbid. TOMLYAML JSONAll formats tomllib read-only, 3.11+; tomli-w to write safe_load only; quote "3.10" and "on" no comments — sibling docs file or use TOML validate with extra="forbid"
Each format has a quirk, but all share the rule: pick the safe loader, then validate with the model.

Production parity checklist

  • One pydantic model validates the parsed config regardless of format.
  • YAML uses safe_load; ambiguous scalars are quoted.
  • TOML loading accounts for the 3.11 tomllib cutover.
  • extra="forbid" rejects unknown keys.
  • Secrets are referenced from a store, never embedded in the file.

The first item is the one that makes the format choice safe to defer or change: because one model validates the parsed config regardless of format, you can adopt whichever format fits the file today and switch later without risk. The remaining items are the per-format safety notes rolled into the checklist — safe_load and quoting for YAML, the tomllib version cutover for TOML, extra="forbid" everywhere — plus the constant across all configuration: secrets are referenced from a store, never embedded in the file, because a config file is committed and readable. Tick every box and the format decision becomes purely about editing comfort, with the safety and validation handled uniformly underneath regardless of which format a given file happens to use.

Format-choice production-parity checklist One model validates every format, YAML uses safe_load with quoted scalars, TOML accounts for the 3.11 cutover, extra=forbid rejects unknown keys, and secrets come from a store. One pydantic model validates the parsed config regardless of format YAML uses safe_load; ambiguous scalars are quoted TOML loading accounts for the 3.11 tomllib cutover extra="forbid" rejects unknown keys in any format Secrets are referenced from a store, never embedded in the file
One model, safe loaders, forbid-extras, and no secrets — the format choice becomes purely ergonomic.

Key takeaways

Pick TOML for hand-edited config, JSON for machine output, YAML for deep nesting — then validate all three through one model so the choice is purely ergonomic. The formats do not rank absolutely; each is best at a specific use. TOML is the default for hand-edited application settings — comments, explicit typing, and the format Python tooling already speaks. JSON is best for machine-generated config, where its strictness is a feature and no human ever has to hand-edit it. YAML is the most expressive, worth reaching for when you genuinely need anchors or deep nesting, at the cost of implicit-typing surprises and an unsafe default loader. The typing column is the clearest tiebreaker for hand-edited files: TOML and JSON are explicit, so a value is exactly what it looks like, while YAML’s implicit typing is the one genuine footgun, which is why TOML edges out YAML as the default despite YAML’s greater expressiveness.

Because the parsed result of every format flows into the same validated model, the choice is low-stakes and reversible: adopt the format that fits the file, and switch later without touching the model or downstream code. What is not negotiable is the safety underneath — safe_load for YAML, the right loader for each format, extra="forbid" validation, and no secrets in committed files. Get those right and the format becomes a question of editing comfort rather than correctness — which is the whole point of validating every format through one shared model. For nested-structure handling, see Handling Nested Configuration in YAML Safely.