Reading Config Files in Python with tomllib: Stdlib TOML Since 3.11
Python 3.11 ships tomllib in the standard library. Learn to read TOML ops config, layer defaults, validate inputs, and why TOML beats ad-hoc INI or env soup.
- #automation
- #ai
- #python
- #configuration
- #toml
For years the answer to “how do I read a config file in Python” involved a third-party dependency, a half-remembered configparser quirk, or a sprawl of environment variables that nobody could enumerate without grepping the codebase. As of Python 3.11, the standard library ships tomllib, a parser for TOML, the same format that already underpins pyproject.toml. No pip install, no transitive dependency to audit, no version skew. For ops tooling that runs on whatever Python the base image happens to provide, a zero-dependency config parser is a genuinely useful thing. The catch is that tomllib only reads, it does not write, and it gives you raw dictionaries with no validation, so a config layer built on it needs you to add the defaults and the type checks yourself. An AI assistant will draft that layer in seconds and quietly leave out the validation that turns a typo into a clear error instead of a 3 a.m. KeyError.
The Basics: tomllib Reads, It Does Not Write
The first thing to internalize is that tomllib.load takes a binary file handle, not a text one. This trips people up constantly, because every other config loader they have used wants text mode. TOML is defined as UTF-8 and the parser handles decoding itself, so you open in "rb".
import tomllib
from pathlib import Path
def load_toml(path: str | Path) -> dict:
with open(path, "rb") as f: # note: binary mode
return tomllib.load(f)
config = load_toml("deploy.toml")
If you have the config as a string already, use tomllib.loads. There is deliberately no dump or dumps. The standard library reads TOML but does not generate it, on the reasoning that writing TOML cleanly while preserving comments and formatting is a harder problem best left to dedicated libraries. For ops config that is almost always fine, because config files are written by humans and read by programs, not the other way around.
A typical ops config reads naturally and stays readable as it grows:
# deploy.toml
[service]
name = "incident-api"
port = 8787
workers = 4
[service.limits]
timeout_seconds = 30
max_retries = 3
[[targets]]
host = "web-01"
region = "us-east"
[[targets]]
host = "web-02"
region = "us-west"
Tables map to nested dicts, and the [[targets]] array-of-tables syntax maps to a list of dicts. That structure is exactly what you want for a list of hosts or a set of named jobs, and it stays legible in a way that the equivalent JSON or YAML often does not.
Why TOML Beats the Usual Alternatives
The case for TOML over the ad-hoc options is practical, not aesthetic. INI via configparser has no real type system, so every value comes back as a string and you are calling getint and getboolean everywhere, hoping the file matches your expectations. Environment variables are flat, untyped, and invisible until something breaks, and a config spread across forty env vars has no canonical place to read what the program actually expects. YAML has types and nesting but also has the significant-whitespace footguns and the surprising type coercions that have caused real incidents, the classic being a country code or a version string silently parsed as a number or a boolean. TOML gives you explicit types, including first-class datetimes, unambiguous syntax, and a spec small enough to hold in your head, while remaining diff-friendly and comment-friendly for the humans who edit it.
Prompt: “Convert this app’s settings.ini and its environment variables into a single tomllib-based config loader.” The assistant produced a clean loader and a tidy TOML file, but it dropped two settings that had only ever existed as environment variables and never appeared in the INI. The generated code ran fine and was missing real configuration, which is exactly why you diff the parsed result against the old behavior rather than trusting that the migration was complete.
Layering Defaults Over File Values
Real ops config is rarely just one file. You have built-in defaults, a base config checked into the repo, and per-environment overrides. tomllib gives you plain dicts, so layering is a recursive merge. Write the merge explicitly rather than reaching for dict.update, which clobbers entire nested tables instead of merging them key by key.
def deep_merge(base: dict, override: dict) -> dict:
result = dict(base)
for key, value in override.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
DEFAULTS = {
"service": {"workers": 1, "limits": {"timeout_seconds": 10, "max_retries": 0}},
}
def load_config(path: str) -> dict:
return deep_merge(DEFAULTS, load_toml(path))
The isinstance guard on both sides is the part that matters. Without it, a top-level service table in the override would replace the entire defaults service table, silently discarding the limits defaults the user never meant to touch. This is precisely the bug an AI draft introduces when it writes a one-line update, and it is invisible until a config that omits timeout_seconds suddenly has no timeout at all.
Validation: The Step You Cannot Skip
tomllib validates that the file is syntactically valid TOML and nothing more. It does not know that port should be an integer in a sane range, that name is required, or that region must be one of your known values. A raw config["service"]["port"] deep in your code turns a missing key into a KeyError at the worst possible moment. Validate at load time, close to the file, where the error message can name the file and the field.
def validate(config: dict) -> dict:
svc = config.get("service")
if not isinstance(svc, dict):
raise ValueError("missing [service] table")
name = svc.get("name")
if not isinstance(name, str) or not name:
raise ValueError("service.name must be a non-empty string")
port = svc.get("port")
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"service.port must be 1-65535, got {port!r}")
return config
For anything beyond a handful of fields, hand-rolled checks get tedious and you graduate to a schema. Feeding the parsed dict into a dataclass or a typed settings model gives you declarative validation, coercion, and good error messages for free, and it composes cleanly with the layered-defaults pattern above. That is the natural next step once a config grows past a dozen keys.
Handling Parse Errors Gracefully
When the file itself is malformed, tomllib.load raises tomllib.TOMLDecodeError. Catch it explicitly so an editor’s stray tab or a missing quote produces a message a human can act on rather than a stack trace that buries the actual problem.
try:
config = validate(load_config("deploy.toml"))
except tomllib.TOMLDecodeError as exc:
raise SystemExit(f"deploy.toml is not valid TOML: {exc}")
except (ValueError, FileNotFoundError) as exc:
raise SystemExit(f"config error: {exc}")
A clean SystemExit with a readable message is the difference between an operator fixing a typo in ten seconds and filing a ticket against your tool.
The pattern that holds all of this together is the same one that makes any AI-assisted change safe: let the model draft the loader and the TOML, then verify the two things it reliably gets wrong, which are merging nested tables correctly and validating the values instead of trusting them. For deeper treatments of typed config, see python-config-loader-dataclasses-validation and python-pydantic-settings-config, and for handling precedence between files, environment, and CLI flags, python-dataclass-to-cli-config-precedence covers the ordering rules. The wider bash and Python automation category has the rest of the configuration tooling.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.