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.
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.
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.
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.
Gotchas & version-specific behaviour
tomllibis read-only and stdlib in 3.11+; on 3.10 usetomli. To write TOML, usetomli-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.
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
tomllibcutover. 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.
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.