tomllib, available in the standard library since Python 3.11, reads TOML 1.0 into dictionaries, lists, and corresponding date types. It can inspect configuration and pyproject.toml without another dependency.

from pathlib import Path
import tomllib


def load_config(path: Path) -> dict[str, object]:
    if path.stat().st_size > 1_000_000:
        raise ValueError("configuration file is too large")

    with path.open("rb") as file:
        data = tomllib.load(file)

    app = data.get("app")
    if not isinstance(app, dict) or not isinstance(app.get("port"), int):
        raise ValueError("app.port is required")
    return data

Successful parsing proves syntax, not the application contract. Validate tables, types, ranges, and unknown fields. Catch tomllib.TOMLDecodeError to produce a useful error without revealing sensitive content.

Pass decimal.Decimal through parse_float when decimal precision matters. The pyproject.toml guide explains common project tables.

tomllib does not write or preserve comments. Do not expect round-trip editing from its resulting dictionary.

The official tomllib documentation, accessed July 22, 2026, documents type conversion and recommends limiting untrusted input size. Separate parsing, validation, and application of configuration for clearer failures.

Choose load() or loads()

tomllib.load() reads a binary file object. tomllib.loads() reads a Python string. The distinction prevents accidental encoding behavior at the file boundary:

import tomllib

with open("pyproject.toml", "rb") as file:
    project = tomllib.load(file)

fragment = tomllib.loads("""
[server]
host = "127.0.0.1"
port = 8080
""")

TOML files are UTF-8. Open them with "rb" for load() as the API requires; do not pass a text-mode handle. If another source already decoded the document, pass the resulting string to loads(). Reject oversized input before parsing when files or request bodies are not fully trusted, because deeply structured data still consumes CPU and memory.

Malformed syntax raises tomllib.TOMLDecodeError. Its message is useful to a developer but may reveal source text or locations from a sensitive configuration. Wrap it at the application boundary:

def read_toml(path: Path) -> dict[str, object]:
    try:
        with path.open("rb") as file:
            return tomllib.load(file)
    except tomllib.TOMLDecodeError as exc:
        raise ValueError(f"invalid TOML in {path.name}") from exc

Do not catch every exception as “invalid TOML.” FileNotFoundError, PermissionError, and I/O failures describe different operational problems and should remain distinguishable.

Know the converted Python types

TOML strings become str, integers become int, booleans become bool, arrays become lists, and tables become dictionaries. Local dates and times become datetime.date, datetime.time, or naive datetime.datetime; offset date-times become aware datetime.datetime. Application code must not assume every date-like value is an interchangeable string.

from datetime import datetime

data = tomllib.loads("""
released = 2026-08-15T15:00:00Z
maintenance = 2026-08-16
""")

released = data["released"]
if not isinstance(released, datetime) or released.tzinfo is None:
    raise ValueError("released must contain an offset")

Floating-point values normally become float. Supply a callable through parse_float when decimal semantics matter:

from decimal import Decimal

prices = tomllib.loads(
    'monthly_price = 19.90',
    parse_float=Decimal,
)
assert prices["monthly_price"] == Decimal("19.90")

The callable receives the original TOML float token and must not return a dictionary or list. This hook affects floating-point tokens, not integers.

Validate a schema after parsing

Valid TOML is not necessarily valid application configuration. Check required tables, exact types, ranges, mutually exclusive options, and unknown keys. Remember that bool is a subclass of int in Python, so isinstance(True, int) is true. An exact type(value) is int check is sometimes appropriate.

from dataclasses import dataclass

@dataclass(frozen=True)
class ServerConfig:
    host: str
    port: int
    debug: bool

def parse_server(data: dict[str, object]) -> ServerConfig:
    table = data.get("server")
    if not isinstance(table, dict):
        raise ValueError("[server] table is required")

    unknown = set(table) - {"host", "port", "debug"}
    if unknown:
        raise ValueError(f"unknown server fields: {sorted(unknown)}")

    host = table.get("host")
    port = table.get("port")
    debug = table.get("debug", False)
    if not isinstance(host, str) or not host:
        raise ValueError("server.host must be a nonempty string")
    if type(port) is not int or not 1 <= port <= 65535:
        raise ValueError("server.port must be between 1 and 65535")
    if type(debug) is not bool:
        raise ValueError("server.debug must be boolean")
    return ServerConfig(host, port, debug)

Converting the untyped dictionary into an immutable domain object keeps validation in one place and prevents distant code from depending on unchecked nested keys. For larger contracts, a validation library may help, but tomllib itself deliberately performs no schema validation.

Read pyproject.toml defensively

The [project] table is standardized by the Python packaging specification, while tool-specific settings live under [tool.<name>]. A file may legally omit [project] when another build mechanism provides metadata, so inspection code should report absence rather than crash:

def project_name(path: Path) -> str | None:
    with path.open("rb") as file:
        data = tomllib.load(file)
    project = data.get("project")
    if not isinstance(project, dict):
        return None
    name = project.get("name")
    return name if isinstance(name, str) else None

Keys containing dots have TOML-specific meaning unless quoted. Always inspect the parsed nesting rather than guessing from the source spelling. Arrays of tables become lists of dictionaries, so validate every element, not only the container.

Configuration can influence imports, file access, network destinations, or external commands. Parsing does not make those values safe. Apply path containment checks, URL policies, and argument allowlists at the point where values gain power. Never execute Python expressions obtained from TOML.

Merge sources with an explicit precedence

Many applications combine defaults, a TOML file, environment variables, and command-line options. Define the precedence and validate the final result. A shallow dictionary update can accidentally replace an entire nested table, while a generic recursive merge may create combinations your schema never intended. Prefer field-by-field construction of the final typed configuration.

Avoid logging the complete parsed dictionary: credentials and private endpoints may be present. Log which source was loaded and which nonsecret mode was selected. Treat missing optional files differently from unreadable required files.

Understand the read-only boundary

tomllib does not serialize TOML and does not retain comments, whitespace, quote style, or key order as an editing model. Its dictionaries are suitable for consuming values, not round-trip changes. Choose a TOML writer when generating a new file and a style-preserving editor when modifying a human-maintained file. Do not rewrite pyproject.toml from a parsed dictionary and assume the original presentation will survive.

Tests should include valid minimal input, the complete configuration, malformed syntax, missing tables, wrong scalar types, unknown fields, boundary values, Unicode text, date-time variants, and an oversized-file rejection. Keep fixtures small enough that each failure communicates one rule.

The dependable pipeline is: bound and read the input, parse TOML, validate the application contract, convert to typed values, and only then apply the configuration. This separation produces precise errors and ensures syntax acceptance is never mistaken for authorization or business validity.

Plan configuration evolution

Configuration is an interface, so changes need compatibility rules. When renaming a key, decide whether to accept the old spelling temporarily, issue a deprecation warning, or reject it with a migration instruction. Do not silently prefer one of two conflicting keys. If several deployed versions share the same file, document the minimum application version for each option.

A config_version field can help with major structural changes, but it does not replace field validation. Parse the version first, dispatch to the appropriate validator, and convert older structures into one current domain object. Tests should cover every supported version and the message returned for unsupported ones.

Defaults belong in code when they are part of application behavior; examples belong in documented sample files. Copying a sample into production should not introduce secret placeholders that look usable. Validate that required secrets arrive through the approved persistent mechanism instead of inventing insecure fallback values.

If configuration is reloaded at runtime, parse and validate a complete candidate before replacing active state. A partially applied update can leave components disagreeing. Swap immutable configuration atomically where possible, retain the last known good value after a failure, and emit a redacted diagnostic. File-watcher events can arrive more than once or while an editor is replacing a file, so debounce and retry only well-understood transient errors.