If you've opened a Rust project, you've met TOML. If you've touched a Python package that uses
pyproject.toml, you've used it too. TOML — Tom's Obvious Minimal Language —
is the config file format that keeps quietly winning over developers who are tired of YAML's whitespace
footguns and JSON's refusal to allow comments. Created by Tom Preston-Werner, co-founder of GitHub, TOML
was designed around one idea: a config format should be so obvious that you can read it without a spec.
Let's see if it lives up to that promise.
What Exactly Is TOML?
TOML is a configuration file format with three explicit design goals, spelled out right at the top of the official spec: it should be obvious to read, minimal in complexity, and map unambiguously to a hash table (a dictionary/map in most languages). That third goal is the key one — every valid TOML file has exactly one correct parse result. No surprising type coercion, no YAML-style boolean landmines, no ambiguity about whether a value is a string or a number.
The format borrows the section-header style from old INI files but adds proper types, arrays, and nested tables on top. The result is something that feels familiar to anyone who's edited a config file before, but with enough structure that a parser can give you a real typed data model out of it. Version 1.0.0 of the spec was released in January 2021 after years of refinement — you can browse the full TOML spec on GitHub if you want to dig into the edge cases.
The Basics: Key-Value Pairs and Comments
A TOML file is made up of key-value pairs. Keys and values are separated by =,
and comments start with #. Simple.
# Cargo.toml — Rust package manifest
[package]
name = "image-resizer"
version = "0.4.2"
edition = "2021"
authors = ["Ada Lovelace <[email protected]>"]
description = "Fast image resizing with Lanczos3 resampling"
license = "MIT"
repository = "https://github.com/example/image-resizer"
# Integers, floats, booleans — all native types
max_threads = 8
quality_default = 0.85
verbose_logging = falseNo quotes required on most keys. Values are typed — 8 is an integer, 0.85
is a float, false is a boolean. No guessing, no implicit coercion based on how the value looks.
This is the everyday TOML you'll write 90% of the time.
String Types: Basic, Literal, and Multiline
TOML has four string types. This covers every real-world case cleanly:
# Basic strings — double quotes, support escape sequences
greeting = "Hello, \nworld!"
path = "C:\\Users\\ada\\Documents"
# Literal strings — single quotes, no escape processing at all
regex_pattern = '\d{4}-\d{2}-\d{2}'
windows_path = 'C:\Users\ada'
# Multiline basic string — triple double quotes
sql_query = """
SELECT user_id, email, created_at
FROM users
WHERE active = true
AND created_at > '2024-01-01'
ORDER BY created_at DESC
"""
# Multiline literal string — triple single quotes, no escapes
shell_script = '''
#!/bin/bash
echo "Deploying $APP_NAME to $ENV"
kubectl apply -f k8s/
'''The literal string type ('single quotes') is the one people forget exists, and it's
genuinely useful — regex patterns and Windows paths are much cleaner without escape doubling. Pick whichever
quote style means you write fewer backslashes.
Numbers, Booleans, and Datetimes
TOML's native types cover everything you'd actually put in a config file. Notably, it has first-class datetime support — something YAML technically has but handles inconsistently across parsers.
# Integers — underscores allowed as separators (like numeric literals in code)
max_connections = 1_000_000
port = 5432
hex_color = 0xFF6B6B # hex prefix supported
octal_permissions = 0o755 # octal prefix supported
# Floats
pi = 3.14159265
compression_ratio = 1.5e-3
infinity_val = inf # special values: inf, -inf, nan
# Booleans — lowercase only (not True, TRUE, yes, on)
ssl_enabled = true
dry_run = false
# Datetimes — RFC 3339 format
created_at = 2024-03-15T09:30:00Z
updated_at = 2024-03-15T14:22:10+05:30
log_date = 2024-03-15 # local date (no time)
backup_time = 03:00:00 # local time (no date)true and false only —
not True, TRUE, yes, on, or any of the other variants
YAML 1.1 accepts. This is intentional. If you need a string "true", quote it.Tables: The INI-Style Section Headers
Tables in TOML are defined with [header] syntax. Everything below a header belongs
to that table until the next header appears. This is the feature that makes TOML look familiar — it's
essentially INI files but with types.
[database]
host = "db.internal"
port = 5432
name = "app_production"
pool_size = 20
[database.credentials]
username = "app_user"
# Don't put real passwords here — use env vars or a secrets manager
password_env = "DB_PASSWORD"
[server]
host = "0.0.0.0"
port = 8080
workers = 4
[server.tls]
enabled = true
cert_file = "/etc/ssl/certs/app.crt"
key_file = "/etc/ssl/private/app.key"Dotted headers like [database.credentials] create nested tables. The parsed result
is exactly what you'd expect: a database object with a nested credentials object.
You can also write inline tables for simple cases — more on that below.
Arrays and Array of Tables
Arrays in TOML use square brackets and can span multiple lines. The really distinctive TOML feature
is Array of Tables — defined with double brackets [[header]]. This is TOML's
answer to "how do I express a list of objects?" without it looking like JSON.
# Regular arrays — can be split across lines, trailing comma is fine
allowed_origins = [
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000",
]
supported_formats = ["jpeg", "png", "webp", "avif"]
retry_delays_ms = [100, 250, 500, 1000, 2000]
# Array of Tables — [[double brackets]]
# Each [[servers]] header appends a new object to the servers array
[[servers]]
name = "web-01"
ip = "10.0.1.10"
role = "primary"
tags = ["web", "prod"]
[[servers]]
name = "web-02"
ip = "10.0.1.11"
role = "replica"
tags = ["web", "prod"]
[[servers]]
name = "db-01"
ip = "10.0.2.10"
role = "primary"
tags = ["database", "prod"]That [[servers]] syntax parses into an array of three objects — equivalent to
"servers": [{...}, {...}, {...}] in JSON. It's verbose compared to JSON arrays of objects,
but the advantage is readability when each item has many fields. You can see this pattern heavily in
Cargo.toml manifests
for defining multiple binary targets, examples, and bench entries.
Inline Tables: Compact One-Liners
When a table has just a couple of fields and you don't want a whole section header for it, inline tables let you write it on a single line:
[build]
# Inline table — must stay on one line
target = { arch = "x86_64", os = "linux", libc = "musl" }
# Equivalent to writing:
# [build.target]
# arch = "x86_64"
# os = "linux"
# libc = "musl"
[feature_flags]
auth = { enabled = true, rollout_pct = 100 }
search = { enabled = true, rollout_pct = 50 }
beta = { enabled = false, rollout_pct = 0 }Inline tables must stay on a single line and cannot be extended later with a [header].
Use them for small, cohesive value groups — they're great for things like coordinate pairs, build targets,
or simple flag configs. Don't use them when you have more than three or four fields; at that point a regular
table reads better.
TOML in the Real World
TOML has carved out a strong niche in the Rust and Python ecosystems, and is gaining traction elsewhere. Here's where you'll encounter it day-to-day:
- Rust — Cargo.toml: Every Rust project has one. Defines package metadata, dependencies, features, and build targets. The Cargo manifest reference is the most detailed real-world TOML usage guide you'll find.
- Python — pyproject.toml: PEP 518 and PEP 621 standardised TOML as the Python project metadata format. Poetry, Hatch, PDM, and setuptools all read from it.
- Deno: The Deno config file supports TOML in addition to JSON.
- Hugo: The Hugo static site generator accepts TOML as config and front-matter format — you'll see it between
+++delimiters at the top of Markdown files. - uv: The fast Python package manager from Astral uses pyproject.toml for all configuration, making TOML the de-facto standard for new Python tooling.
If you need to convert an existing config between formats, the TOML to JSON and JSON to TOML converters handle the structural mapping for you. Or use the TOML Formatter to clean up inconsistent spacing in an existing file, and the TOML Validator to catch syntax errors before they surface in production.
Parsing TOML in Python
Since Python 3.11, the standard library includes
tomllib
— no external dependency needed. For Python 3.9 and 3.10, the
tomli backport
has an identical API, so you can switch between them with a single import alias.
import sys
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib # pip install tomli
# tomllib only reads binary mode — open with "rb"
with open("pyproject.toml", "rb") as f:
config = tomllib.load(f)
# Types match TOML exactly: str, int, float, bool, datetime, list, dict
project_name = config["project"]["name"] # str
python_requires = config["project"]["requires-python"] # str
dependencies = config["project"]["dependencies"] # list[str]
print(f"Project: {project_name}")
print(f"Requires Python: {python_requires}")
print(f"Dependencies ({len(dependencies)}):")
for dep in dependencies:
print(f" {dep}")
# Parse from a string with tomllib.loads()
raw = """
[server]
host = "localhost"
port = 8080
debug = true
"""
server_config = tomllib.loads(raw)
print(server_config["server"]["port"]) # 8080 (int, not "8080")Note that tomllib.load() requires binary mode ("rb"). This is intentional —
TOML requires UTF-8 encoding, and opening in binary mode lets the parser handle the encoding check itself.
It's a small gotcha that catches people the first time.
Parsing TOML in Rust
In Rust, the toml crate
is the standard choice. It integrates tightly with serde, so you can deserialise directly into
your own structs with minimal boilerplate:
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
struct AppConfig {
server: ServerConfig,
database: DatabaseConfig,
feature_flags: FeatureFlags,
}
#[derive(Debug, Deserialize)]
struct ServerConfig {
host: String,
port: u16,
workers: usize,
}
#[derive(Debug, Deserialize)]
struct DatabaseConfig {
host: String,
port: u16,
name: String,
pool_size: u32,
}
#[derive(Debug, Deserialize)]
struct FeatureFlags {
enable_beta: bool,
max_upload_mb: u32,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let raw = fs::read_to_string("config.toml")?;
let config: AppConfig = toml::from_str(&raw)?;
println!("Server: {}:{}", config.server.host, config.server.port);
println!("DB pool size: {}", config.database.pool_size);
println!("Beta enabled: {}", config.feature_flags.enable_beta);
Ok(())
}Add toml = "0.8" and serde = { version = "1", features = ["derive"] }
to your [dependencies] in Cargo.toml and you're set. The serde derive macros handle all the
field mapping. If your struct field names use snake_case but the TOML keys use kebab-case, add
#[serde(rename_all = "kebab-case")] at the struct level and everything maps automatically.
TOML vs YAML vs JSON — When to Pick Which
This question comes up on every new project. Here's the honest breakdown:
- TOML: Best for config files that humans write and maintain, where typed values matter and nesting is shallow-to-moderate. Sweet spot: app configs, build manifests, tool settings. Falls apart for deep nesting — 4+ levels becomes awkward with repeated section headers.
- YAML: Best when you're writing structured data with lots of list-of-objects (Kubernetes manifests, GitHub Actions workflows). Multi-line string support is genuinely better than TOML's. The downside: whitespace-sensitivity and YAML 1.1 type coercion create real bugs in the wild.
- JSON: Best for machine-to-machine data exchange, APIs, and when you need the broadest possible toolchain support. Not great for human-maintained config — no comments, and string escaping is tedious.
A Real pyproject.toml
To put everything together, here's a realistic pyproject.toml for a Python library —
the kind you'd find on a modern open-source project. Notice how the format carries a lot of structured
information while still being easy to scan:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "httpx-cache"
version = "1.2.0"
description = "Transparent HTTP caching layer for httpx"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.9"
authors = [
{ name = "Ada Lovelace", email = "[email protected]" },
]
keywords = ["http", "cache", "httpx", "async"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"httpx>=0.25.0",
"anyio>=4.0.0",
]
[project.optional-dependencies]
redis = ["redis>=5.0.0"]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.23.0",
"coverage[toml]>=7.3.0",
"ruff>=0.1.0",
"mypy>=1.7.0",
]
[project.urls]
Homepage = "https://github.com/example/httpx-cache"
Changelog = "https://github.com/example/httpx-cache/blob/main/CHANGELOG.md"
[tool.ruff]
line-length = 100
target-version = "py39"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
[tool.mypy]
strict = true
python_version = "3.9"
[tool.coverage.run]
source = ["httpx_cache"]
branch = true
[tool.coverage.report]
fail_under = 90This is real TOML doing real work. Each [tool.x] section is a separate namespace
for a different tool — ruff, mypy, coverage — all living in one file without stepping on each other.
No deep nesting required, everything is readable at a glance.
Wrapping Up
TOML delivers on its promise: readable, unambiguous, and cleanly typed. If you're starting a new project in Rust, Python, or Go, TOML is worth defaulting to for config files — especially files that will be committed to source control and edited by multiple people. The lack of whitespace-sensitivity alone makes it a relief compared to YAML. For working with TOML files directly in your browser, the TOML Formatter and TOML Validator handle the most common tasks. And if you're migrating an existing project from JSON or need to bridge TOML into a JSON-based pipeline, the TOML to JSON and JSON to TOML converters have you covered.