TOML has become the default configuration format for Python tooling — pyproject.toml, ruff.toml, uv.toml — and it is a good choice for your own CLI too: it is readable, it has real types (integers, booleans, dates, arrays, tables), and since Python 3.11 the standard library can parse it with tomllib, so reading it costs no dependency. But tomllib.load() only gets you a dictionary. Between that dictionary and a configuration your code can trust sit several decisions: what happens when the file is missing, how to report a syntax error, how to catch a misspelt key that would otherwise be silently ignored, and how to make sure replicas = "3" is rejected rather than crashing later. This guide builds a small loader that answers each of those, returning a typed dataclass and errors that tell users exactly what to fix. It belongs to the handling configuration files and environment variables topic.
Prerequisites
- Python 3.11+ for
tomllib; on 3.10, thetomlibackport provides the identical API (uv add "tomli; python_version < '3.11'"). - A decision about where the file lives — typically the user config directory from storing app data with platformdirs, a project file found by walking up directories, or both.
From file to typed settings
The pipeline has four steps: open the file in binary mode (tomllib requires bytes, which also means it handles the UTF-8 encoding itself — no Windows code-page surprises), parse it into dictionaries and lists, validate keys and types, and build a frozen dataclass the rest of the program uses. Nothing downstream ever touches the raw dictionary.
TOML's types map cleanly onto Python's, with a couple of details worth knowing:
The recipe
# src/mytool/config.py
from __future__ import annotations
import difflib
import sys
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any
if sys.version_info >= (3, 11):
import tomllib
else: # Python 3.10: the same API from the tomli backport
import tomli as tomllib
class ConfigError(Exception):
pass
@dataclass(frozen=True)
class DeployConfig:
region: str = "eu-west-1"
replicas: int = 2
tags: tuple[str, ...] = ()
@dataclass(frozen=True)
class Config:
api_url: str = "https://api.example.com"
timeout: float = 30.0
deploy: DeployConfig = field(default_factory=DeployConfig)
def _check_keys(section: str, data: dict[str, Any], cls: type) -> None:
known = {f.name for f in fields(cls)}
for key in data:
if key not in known:
close = difflib.get_close_matches(key, known, n=1)
hint = f" (did you mean {close[0]!r}?)" if close else ""
raise ConfigError(f"unknown key {key!r} in [{section}]{hint}")
def _typed(section: str, key: str, value: Any, expected: type | tuple[type, ...]) -> Any:
if isinstance(value, bool) and expected is not bool: # bool is an int subclass
raise ConfigError(f"[{section}] {key} must be {getattr(expected, '__name__', expected)}, got a boolean")
if not isinstance(value, expected):
name = expected.__name__ if isinstance(expected, type) else " or ".join(t.__name__ for t in expected)
raise ConfigError(f"[{section}] {key} must be {name}, got {type(value).__name__} {value!r}")
return value
def parse(data: dict[str, Any]) -> Config:
_check_keys("top level", data, Config)
deploy_raw = data.get("deploy", {})
if not isinstance(deploy_raw, dict):
raise ConfigError("[deploy] must be a table")
_check_keys("deploy", deploy_raw, DeployConfig)
deploy = DeployConfig(
region=_typed("deploy", "region", deploy_raw.get("region", DeployConfig.region), str),
replicas=_typed("deploy", "replicas", deploy_raw.get("replicas", DeployConfig.replicas), int),
tags=tuple(_typed("deploy", "tags", deploy_raw.get("tags", []), list)),
)
if deploy.replicas < 1:
raise ConfigError("[deploy] replicas must be at least 1")
return Config(
api_url=_typed("top level", "api_url", data.get("api_url", Config.api_url), str),
timeout=float(_typed("top level", "timeout", data.get("timeout", Config.timeout), (int, float))),
deploy=deploy,
)
def load(path: Path) -> Config:
try:
with path.open("rb") as fh: # tomllib requires binary mode
data = tomllib.load(fh)
except FileNotFoundError:
return Config() # no file: all defaults
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"{path}: invalid TOML: {exc}") from None
try:
return parse(data)
except ConfigError as exc:
raise ConfigError(f"{path}: {exc}") from None
A matching config file:
# ~/.config/mytool/config.toml
api_url = "https://staging.example.com"
timeout = 60
[deploy]
region = "us-east-1"
replicas = 4
tags = ["web", "blue"]
Why the loader looks like this
A missing file means defaults, not an error. Most users never create a config file; the tool must work without one. Only a file that exists and is wrong is an error.
Syntax errors keep their position. tomllib.TOMLDecodeError includes the line and column (Invalid value (at line 1, column 11)); prefixing the file path gives the user everything needed to open the file at the right place.
Unknown keys are errors, with suggestions. A typo like replcas = 3 is otherwise silently ignored — the most frustrating kind of config bug, because nothing fails and the setting simply does not apply. difflib.get_close_matches turns it into "unknown key 'replcas' in deploy (did you mean 'replicas'?)".
Types are checked explicitly. TOML is typed, so replicas = "3" is a string and should be reported, not coerced. The bool check matters because True is an instance of int in Python; without it, replicas = true would pass as 1. Integers are accepted where floats are expected, since timeout = 60 is a natural thing to write.
Constraints live next to types. "replicas must be at least 1" is checked while building the dataclass, so the error names the section and key.
The result is frozen. A frozen=True dataclass cannot be mutated by code halfway through a command, which keeps the configuration a single, predictable source of truth.
For larger configurations, a validation library does the key and type checking declaratively — pydantic, attrs with cattrs, or msgspec. Typed settings with pydantic-settings shows that approach, including merging environment variables. The hand-written loader above has the advantage of no dependencies and no import cost, which matters for a CLI's startup time.
Writing TOML back
tomllib only reads. If your CLI has a config set command, you need a writer:
tomli-wwrites dictionaries as TOML. It is small and fast, but it does not preserve comments or formatting — rewriting a user's hand-edited file with it discards their comments.tomlkitparses into a document object that preserves comments, whitespace and ordering, and writes it back almost unchanged. It is the right choice for editing files people also edit by hand.
import tomlkit
doc = tomlkit.parse(path.read_text(encoding="utf-8"))
doc.setdefault("deploy", tomlkit.table())["replicas"] = 5
path.write_text(tomlkit.dumps(doc), encoding="utf-8") # comments survive
Write the result atomically so an interrupted config set cannot leave an empty file, as described in writing files atomically in Python CLIs.
UX considerations
- Always name the file. Users may have a user config, a project config and a system config; "invalid TOML" without a path sends them searching.
- Report the first problem precisely rather than all problems vaguely. For hand-edited files, one precise message with a line number or key is more useful than a list of generic complaints.
- Offer
config showandconfig path. Showing the effective configuration, and where it came from, answers most "why is it doing that?" questions. The precedence rules that combine files with flags and environment variables are in config precedence: flags, env, files and defaults. - Ship a commented example. A
config initcommand that writes a fully commented default file teaches every option in place.
Testing the behaviour
Test the loader with small files in tmp_path, and give every error its own parametrised case — the messages are part of the interface:
# tests/test_config.py
from pathlib import Path
import pytest
from mytool.config import Config, ConfigError, load
def write(tmp_path: Path, text: str) -> Path:
p = tmp_path / "mytool.toml"
p.write_text(text, encoding="utf-8")
return p
def test_missing_file_gives_defaults(tmp_path):
assert load(tmp_path / "nope.toml") == Config()
def test_full_file(tmp_path):
cfg = load(write(tmp_path, 'api_url = "https://staging.example.com"\ntimeout = 60\n\n'
'[deploy]\nregion = "us-east-1"\nreplicas = 4\ntags = ["web", "blue"]\n'))
assert cfg.timeout == 60.0 and cfg.deploy.replicas == 4 and cfg.deploy.tags == ("web", "blue")
@pytest.mark.parametrize("text, fragment", [
('timeout = \n', "invalid TOML"),
('[deploy]\nreplcas = 3\n', "did you mean 'replicas'"),
('[deploy]\nreplicas = "3"\n', "replicas must be int, got str"),
('[deploy]\nreplicas = true\n', "got a boolean"),
('[deploy]\nreplicas = 0\n', "at least 1"),
('deploy = "eu"\n', "must be a table"),
])
def test_errors_name_the_problem(tmp_path, text, fragment):
with pytest.raises(ConfigError, match=fragment) as info:
load(write(tmp_path, text))
assert "mytool.toml" in str(info.value)
Each failure case asserts on the specific message and that the file name appears, so a refactor cannot quietly degrade the errors users depend on.
Conclusion
tomllib makes TOML configuration free in Python 3.11+, but the dictionary it returns is only the start. Open files in binary mode, treat a missing file as defaults, keep the line and column of syntax errors, reject unknown keys with a suggestion, check types strictly — remembering that booleans are integers in Python — enforce constraints where you build the typed object, and return a frozen dataclass. Use tomlkit when you need to edit files users also edit. Every configuration mistake then produces one clear message that names the file and the fix.
Frequently asked questions
TOML or YAML for a CLI's config?
TOML for most CLIs: it is unambiguous, typed, and parsed by the standard library. YAML suits deeply nested data and is familiar to Kubernetes users, but it has surprising implicit typing and needs a third-party parser; if you do use it, follow loading YAML configs safely in CLI apps.
Can my tool read its settings from pyproject.toml?
Yes — many tools read a [tool.mytool] table from the project's pyproject.toml. Load the file with tomllib and take data.get("tool", {}).get("mytool", {}), then pass that dictionary to the same parse function.
How do I support environment variables for the same settings?
Build the dataclass from the file, then override fields from environment variables and flags in a fixed order. pydantic-settings does this automatically; by hand, it is a few lines per field.
Why not just use configparser and INI files?
INI has no types (everything is a string), no nesting beyond one level of sections, and inconsistent quoting rules. TOML fixes all three, and tomllib is equally standard.