Skip to content
← All articles

tomllib: config parsing is where untyped data enters your program

tomllib.load returns dict[str, Any], so teaching it without the boundary conversion produces code that passes --strict while being completely unchecked.

tomllib (3.11) parses TOML with no third-party dependency. It is a small module with a small API and one large lesson attached.

import tomllib

with open("config.toml", "rb") as f:      # note: BINARY mode
    document = tomllib.load(f)

Binary mode is required. TOML is defined to be UTF-8, and the parser handles decoding itself. Opening in text mode raises TypeError, which is mildly annoying and entirely correct: it removes the possibility of the file being decoded with the platform’s locale encoding, so a config file that works on your laptop and fails in a container is one class of bug that simply cannot happen here. tomllib.loads takes a str for when you already have the text.

There is no writing support, deliberately. If you need to emit TOML, use tomli-w (plain) or tomlkit (preserves comments and formatting).

Two version notes: 3.14 gives TOMLDecodeError structured lineno and colno attributes rather than only a message, and 3.15 upgrades the parser to TOML 1.1.0 — so a document that is valid on 3.15 may fail to parse on 3.14. If you support both, do not use 1.1.0 features.

The actual lesson

document = tomllib.load(f)     # dict[str, Any]

Everything you take out of that dict is Any. Any is not “unknown” to a type checker — it is “assume this is whatever you need it to be”, so it propagates silently through your entire program and switches checking off wherever it lands.

def get_port(document: dict[str, Any]) -> int:
    return document["server"]["port"]      # no error from the return itself

This is where --warn-return-any (included in --strict) earns its keep: it is the one flag that fires on returning an Any from a function declared to return something specific, and it is the cleanest demonstration in the whole course of --strict catching a real bug rather than a style issue. The function claims to return an int. Nothing has checked that. A config with port = "8080" produces a str that flows into your socket call and fails somewhere else entirely.

💡--warn-return-any catches return document["server"]["port"]. click to reveal

Why does cast(int, document["server"]["port"]) silence it, and why is that worse than a bug? cast is an unchecked assertion. It generates no runtime code at all and simply instructs the checker to believe you. Silencing the warning that way converts “the checker is telling me this is unverified” into “the checker has been told to stop telling me”, with the same unverified value flowing onward.

It is worse than the original bug because the original at least left evidence. A future reader sees cast(int, ...) and reasonably assumes someone verified it — casts are how you communicate “I know something the checker does not”. A cast that encodes a hope rather than a fact is a lie in the source.

The honest fix is a runtime check that narrows:

port = server.get("port")
if isinstance(port, bool) or not isinstance(port, int):
    raise ConfigError("server.port", "expected integer")

After that isinstance, mypy knows port is an int because it has seen the proof. Use cast only where you genuinely have information the checker cannot — never to quiet a warning about untrusted data.

What the boundary function looks like

@dataclass(frozen=True, slots=True)
class ServerConfig:
    host: str
    port: int
    workers: int = 4
    debug: bool = False


def load_config(stream: BinaryIO) -> ServerConfig:
    ...     # parse, validate each field, raise ConfigError with field + reason

The whole program past this point deals in ServerConfig, where port is provably an int. The Any stops here, in one function, whose job is precisely to convert untrusted data into a typed value or an error.

Note the isinstance(port, bool) check in the snippet above. bool is a subclass of int in Python, so a TOML port = true passes isinstance(x, int) and gives you True where you expected a port number. Any integer validation at a boundary needs the bool exclusion.

💡Your ConfigError carries a field name and a reason. Why not click to reveal

just let the KeyError or TypeError propagate? Because the audience for a config error is an operator, not a developer.

KeyError: 'host' names a dict key, in a traceback through your parsing code, with no indication of which file, which table, or what a valid value looks like. The operator’s next step is to read your source. ConfigError("server.host", "missing") names the thing they have to fix in the vocabulary of the file they wrote.

There is a structural reason too: a raw KeyError is indistinguishable from a KeyError raised by a bug in your own parsing logic. Catching it at the call site means catching both, so a typo in your code reports as a user configuration problem. A dedicated exception type separates “the config is wrong” from “the parser is wrong”, which is exactly the distinction the person reading the log needs.