Beginner
Configuration files are one of those things every Python project eventually needs: a place to store database credentials, API endpoints, feature flags, and application settings that should not be hardcoded into your source files. The traditional options — .ini files parsed by configparser, JSON files that do not support comments, or YAML files that require a third-party library — all have frustrating limitations. TOML (Tom’s Obvious Minimal Language) solves most of them: it is human-readable, supports comments, handles dates and arrays natively, and maps cleanly to Python dictionaries. Since Python 3.11, you can parse TOML without installing anything — it is in the standard library as tomllib.
If you have worked with a modern Python project, you have already encountered TOML. The pyproject.toml file that Poetry, Hatch, PDM, and setuptools all use to define project metadata is TOML. The [tool.pytest.ini_options] section for pytest configuration is TOML. The [tool.ruff] linting configuration is TOML. The format has quietly become the standard for Python tooling configuration, and tomllib gives you first-class access to it from your own code. For Python 3.10 and earlier, the third-party tomli package provides the identical API.
This article covers: the TOML syntax you need to know (strings, integers, booleans, arrays, tables, and inline tables), reading a TOML file with tomllib.load(), accessing nested configuration values, handling missing keys safely with .get(), reading pyproject.toml for project metadata, validating configuration with dataclasses, writing a configuration loader class, and a practical application config pattern you can drop into any project. No installation required for Python 3.11+.
Reading TOML in Python: Quick Example
Here is the complete minimal workflow: a TOML config file and the three lines of Python needed to read it.
# config.toml
[database]
host = "localhost"
port = 5432
name = "myapp"
[app]
debug = false
max_connections = 10
allowed_hosts = ["localhost", "127.0.0.1"]
# read_config.py
import tomllib
with open("config.toml", "rb") as f:
config = tomllib.load(f)
print(config["database"]["host"]) # localhost
print(config["database"]["port"]) # 5432
print(config["app"]["allowed_hosts"]) # ['localhost', '127.0.0.1']
print(type(config["app"]["debug"])) # <class 'bool'>
localhost
5432
['localhost', '127.0.0.1']
<class 'bool'>
Three things to notice: the file is opened in binary mode ("rb", not "r") — this is required by tomllib and is not optional. The resulting config is a plain Python dictionary, so all standard dict operations work on it. The TOML false became a Python bool automatically — tomllib maps TOML types to their Python equivalents without any manual conversion. The sections below explore the full TOML type system and more complex configuration patterns.
What is TOML and How Does It Compare to JSON and YAML?
TOML stands for Tom’s Obvious Minimal Language. It was created by Tom Preston-Werner (co-founder of GitHub) in 2013 as a configuration file format that is easier to read than JSON (because it supports comments and has cleaner syntax) and more predictable than YAML (because YAML has notoriously surprising indentation and type coercion rules).
| Feature | TOML | JSON | YAML | INI |
|---|---|---|---|---|
| Comments | Yes (#) | No | Yes (#) | Yes (; or #) |
| Nested structures | Yes (tables) | Yes | Yes | Limited |
| Arrays | Yes | Yes | Yes | No |
| Dates/times | Native | String only | Yes | No |
| Python stdlib | 3.11+ (tomllib) | Yes (json) | No (requires PyYAML) | Yes (configparser) |
| Readable by humans | High | Medium | High (until it isn’t) | Medium |
The key TOML advantage over JSON for configuration files is comments — being able to document why a setting exists is essential when multiple people work on a project. The advantage over YAML is predictability: TOML has fewer implicit type coercions and indentation has no semantic meaning, which eliminates a whole class of subtle bugs.
TOML Syntax Essentials
You do not need to memorize the full TOML specification to use it effectively for application config. Here are the constructs that cover 95% of real-world use cases.
# toml_examples.toml
# This is a comment -- TOML supports them, unlike JSON
# --- Scalar types ---
name = "MyApp" # String (double or single quotes)
version = "1.0.0"
max_retries = 3 # Integer
timeout = 30.5 # Float
debug = false # Boolean (true/false, lowercase)
start_date = 2026-01-15 # Local date (TOML native type)
created_at = 2026-01-15T10:30:00 # Local datetime
# --- Arrays ---
ports = [8080, 8081, 8082]
tags = ["web", "api", "python"]
matrix = [[1, 2], [3, 4]] # Arrays of arrays
# --- Tables (sections) ---
[database]
host = "db.example.com"
port = 5432
# --- Nested tables ---
[database.replica]
host = "replica.example.com"
port = 5433
# --- Inline tables (single line) ---
[server]
address = {host = "0.0.0.0", port = 8080}
# --- Array of tables (list of dicts) ---
[[users]]
name = "alice"
role = "admin"
[[users]]
name = "bob"
role = "viewer"
The [[double bracket]] syntax creates an array of tables — each [[users]] entry appends a new dictionary to the users list. This is the TOML way to represent what would be a JSON array of objects. The dot notation in [database.replica] is equivalent to a nested dict: config["database"]["replica"] in Python.
Loading and Accessing Configuration
Once you have a TOML file, reading it is two lines. The challenge is accessing nested values safely — especially when some keys may be optional or provided by different environments.
# load_config.py
import tomllib
def load_config(path: str) -> dict:
"""Load a TOML config file and return it as a dict."""
with open(path, "rb") as f:
return tomllib.load(f)
config = load_config("config.toml")
# Direct access (raises KeyError if missing)
db_host = config["database"]["host"]
# Safe access with defaults using .get()
db_port = config.get("database", {}).get("port", 5432)
debug = config.get("app", {}).get("debug", False)
log_level = config.get("logging", {}).get("level", "INFO")
print(f"Database: {db_host}:{db_port}")
print(f"Debug mode: {debug}")
print(f"Log level: {log_level}")
# Access array of tables
for user in config.get("users", []):
print(f" User: {user['name']} ({user['role']})")
Database: localhost:5432
Debug mode: False
Log level: INFO
User: alice (admin)
User: bob (viewer)
The double .get() pattern — config.get("section", {}).get("key", default) — safely handles both a missing section and a missing key within a present section. Without the empty dict fallback, a missing section would cause .get() to return None, and then calling .get() on None would raise AttributeError. This pattern is defensive and verbose; for larger configs, a dataclass-based validator (shown later) is more maintainable.
Reading pyproject.toml for Project Metadata
One practical use of tomllib is reading your own project’s pyproject.toml at runtime — for example, to display the application version without hardcoding it in two places.
# read_pyproject.py
import tomllib
from pathlib import Path
def get_project_version() -> str:
"""Read the version string from pyproject.toml."""
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
if not pyproject_path.exists():
return "unknown"
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
# Standard location for Poetry and setuptools
return data.get("tool", {}).get("poetry", {}).get("version") \
or data.get("project", {}).get("version", "unknown")
def get_pytest_config() -> dict:
"""Read pytest configuration from pyproject.toml."""
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
return data.get("tool", {}).get("pytest.ini_options", {})
print(f"Project version: {get_project_version()}")
pytest_cfg = get_pytest_config()
if pytest_cfg:
print(f"Pytest testpaths: {pytest_cfg.get('testpaths', ['tests'])}")
Project version: 1.2.0
Pytest testpaths: ['tests', 'integration_tests']
This pattern lets you maintain a single source of truth for the version string: update it in pyproject.toml and every part of the application that calls get_project_version() picks up the change automatically. The Path(__file__).parent.parent navigates from the module file up to the project root — adjust the number of .parent calls based on your directory structure.
Validating Config with Dataclasses
Raw dictionary access works but gives you no type checking and no clear picture of which fields are required. Wrapping your config in a dataclass provides IDE autocomplete, type annotations, and a clear schema for what valid configuration looks like.
# config_schema.py
import tomllib
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class DatabaseConfig:
host: str
port: int = 5432
name: str = "app"
user: str = "postgres"
password: str = ""
@dataclass
class AppConfig:
debug: bool = False
log_level: str = "INFO"
allowed_hosts: list = field(default_factory=list)
database: DatabaseConfig = field(default_factory=DatabaseConfig)
@classmethod
def from_toml(cls, path: str) -> "AppConfig":
with open(path, "rb") as f:
data = tomllib.load(f)
db_data = data.get("database", {})
return cls(
debug=data.get("app", {}).get("debug", False),
log_level=data.get("app", {}).get("log_level", "INFO"),
allowed_hosts=data.get("app", {}).get("allowed_hosts", []),
database=DatabaseConfig(**db_data) if db_data else DatabaseConfig(host="localhost"),
)
# Usage
config = AppConfig.from_toml("config.toml")
print(f"DB host: {config.database.host}")
print(f"DB port: {config.database.port}")
print(f"Debug: {config.debug}")
print(f"Hosts: {config.allowed_hosts}")
DB host: localhost
DB port: 5432
Debug: False
Hosts: ['localhost', '127.0.0.1']
The from_toml() classmethod is the idiomatic pattern for this: it reads raw TOML, maps values to the dataclass fields, and returns a typed configuration object. Your application code only sees AppConfig objects with dot-attribute access, not raw dict lookups. This approach scales well — as configuration grows more complex, you add nested dataclasses and the validation logic stays in one place.
Real-Life Example: Multi-Environment Configuration Loader
# env_config_loader.py
"""
Loads a base config.toml and merges environment-specific overrides.
Usage: CONFIG_ENV=production python myapp.py
"""
import tomllib
import os
from pathlib import Path
from dataclasses import dataclass, field
CONFIG_DIR = Path("config")
def load_toml(path: Path) -> dict:
if not path.exists():
return {}
with open(path, "rb") as f:
return tomllib.load(f)
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base."""
result = base.copy()
for key, val in override.items():
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
result[key] = deep_merge(result[key], val)
else:
result[key] = val
return result
def load_config() -> dict:
env = os.getenv("CONFIG_ENV", "development")
base = load_toml(CONFIG_DIR / "config.toml")
override = load_toml(CONFIG_DIR / f"config.{env}.toml")
merged = deep_merge(base, override)
print(f"Loaded config for environment: {env}")
return merged
config = load_config()
print(f"DB host: {config.get('database', {}).get('host')}")
print(f"Debug: {config.get('app', {}).get('debug')}")
print(f"Log level: {config.get('app', {}).get('log_level')}")
Loaded config for environment: development
DB host: localhost
Debug: True
Log level: DEBUG
This loader reads config/config.toml as the base configuration and then reads config/config.development.toml (or config.production.toml, etc.) as an override layer. The deep_merge() function recursively merges the two dictionaries so environment-specific files only need to specify what differs from the base. Set CONFIG_ENV=production before running to load production overrides. This pattern avoids duplicating shared configuration across environment files while still allowing clean environment-specific customization.
Frequently Asked Questions
Can tomllib write TOML files?
tomllib is read-only — it can only parse existing TOML files. To write TOML from Python, use the third-party tomli-w package (pip install tomli-w). It provides a tomli_w.dumps(data) function that serializes a Python dict to a TOML string. For most application configuration use cases, you write config files by hand and only read them from Python, so the read-only stdlib module covers the vast majority of needs.
What if I’m on Python 3.10 or earlier?
Install the tomli package (pip install tomli). It provides the identical API as tomllib and was the reference implementation that was accepted into the standard library. A common compatibility pattern: try: import tomllib except ImportError: import tomli as tomllib. This single try/except block makes your code work on Python 3.10 and earlier without any other changes.
Why does tomllib require binary mode (“rb”) instead of text mode (“r”)?
TOML requires UTF-8 encoding. Opening in binary mode and letting tomllib handle the UTF-8 decoding internally guarantees correct behavior regardless of the platform’s default encoding (which can be UTF-8 on Linux/macOS but something different on some Windows configurations). If you have a TOML string rather than a file, use tomllib.loads(string) — note the s suffix, which accepts a Python str rather than a binary file object.
Should I put secrets like database passwords in TOML files?
No. TOML files (especially config.toml) should not contain secrets because they often end up in version control. Use TOML for non-sensitive configuration and use environment variables (read with os.environ.get()) or a secrets manager for passwords and API keys. A common pattern is to read the database password from an environment variable as the fallback: password = os.environ.get("DB_PASSWORD") or config["database"].get("password", ""). Never commit config.production.toml if it contains any credentials.
When should I use TOML vs. environment variables for configuration?
Use TOML for structured, multi-key configuration that is the same across developer machines and different runs: database hostnames, feature flags, logging levels, allowed hosts. Use environment variables for values that differ per deployment or contain secrets: database passwords, API keys, environment name (production/staging/dev). Many projects use both: a TOML file for the structure and environment variables to inject the secrets at runtime, which is exactly the pattern shown in the multi-environment loader above.
Conclusion
Python’s tomllib module brings first-class TOML parsing to the standard library with no installation required on Python 3.11+. You learned the core TOML syntax including scalars, arrays, tables, and the [[array of tables]] pattern; how to open files in binary mode and load them with tomllib.load(); safe key access patterns with .get(); reading pyproject.toml for project metadata; wrapping raw config dicts in typed dataclasses; and building a multi-environment configuration loader with deep merging. TOML is now the standard format for Python tooling and project configuration — tomllib is the clean, dependency-free way to work with it.
Extend the multi-environment loader by adding validation that raises clear error messages for missing required keys, or combine it with Pydantic’s BaseSettings for automatic environment variable injection and type coercion. Official documentation: docs.python.org/3/library/tomllib.html.