Beginner

Every non-trivial Python application needs a configuration file. Whether you are storing database credentials, feature flags, API endpoints, or user preferences, you need a format that is easy for humans to read and edit, but structured enough for your code to parse reliably. For years, Python developers defaulted to INI files with configparser, but INI has serious limitations — no native data types, no nested sections, and everything is a string. TOML fixes all of that while staying just as readable, and since Python 3.11, you can parse it without installing anything.

Python 3.11 added tomllib to the standard library for reading TOML files. For writing TOML, the community standard is tomli_w, a lightweight package you can install with pip install tomli_w. Between these two tools, you have everything you need to replace INI, JSON, or YAML configuration files with something cleaner and more powerful. If you are on Python 3.10 or earlier, the tomli package provides the same reading API as tomllib.

In this article we will cover everything you need to work with TOML in Python. We will start with a quick example to get you up and running, then explain what TOML is and how it compares to INI, JSON, and YAML. From there we will dive into reading TOML files, understanding TOML data types, working with nested tables and arrays, writing TOML files, and handling common patterns like environment-specific configs. We will finish with a real-life project that builds a complete application configuration system. By the end, you will be ready to use TOML for all your Python configuration needs.

Python TOML Configuration: Quick Example

Here is a complete example that creates a TOML configuration file and reads it back. You can run this immediately to see TOML in action.

# quick_example.py
import tomllib
from pathlib import Path

# First, create a sample TOML config file
config_content = """
[app]
name = "MyWebApp"
version = "2.1.0"
debug = false

[database]
host = "localhost"
port = 5432
name = "myapp_db"
"""
Path("config.toml").write_text(config_content)

# Now read it back
with open("config.toml", "rb") as f:
    config = tomllib.load(f)

print(f"App: {config['app']['name']} v{config['app']['version']}")
print(f"Debug mode: {config['app']['debug']}")
print(f"Database: {config['database']['host']}:{config['database']['port']}")

Output:

App: MyWebApp v2.1.0
Debug mode: False
Database: localhost:5432

Notice something important: the debug value came back as a Python bool (capital-F False), and the port came back as an int. TOML preserves data types natively — unlike INI files where everything is a string and you have to convert manually. The tomllib.load() function reads the file in binary mode (that is why we open with "rb") and returns a regular Python dictionary.

Want to go deeper? Below we explain the TOML format in detail, compare it to alternatives, and build a production-ready configuration system you can drop into any project.

What Is TOML and Why Use It Instead of INI?

TOML stands for “Tom’s Obvious, Minimal Language.” It was created by Tom Preston-Werner (co-founder of GitHub) as a configuration file format that is easy for humans to read and write while being unambiguous for machines to parse. If you have ever used an INI file or edited a pyproject.toml for a Python package, you have already seen TOML in action — it is the official configuration format for Python packaging tools like pip, setuptools, and poetry.

The key advantage of TOML over INI is that TOML has real data types. In an INI file, the value port = 5432 is the string "5432", and you need to call int() to convert it. In TOML, that same line produces an actual integer. TOML also supports nested sections (tables within tables), arrays, dates, and inline tables — none of which INI can handle without awkward workarounds.

Here is how TOML compares to the other popular configuration formats.

FeatureTOMLINIJSONYAML
Human readableExcellentGoodModerateGood
CommentsYes (#)Yes (;/#)NoYes (#)
Native data typesString, int, float, bool, datetime, array, tableStrings onlyString, number, bool, null, array, objectAll of the above plus anchors
Nested sectionsYes (dotted keys and tables)NoYesYes
Python stdlib supportRead only (tomllib, 3.11+)Read/write (configparser)Read/write (json)No (needs PyYAML)
Trailing commasYes (in arrays)N/ANoN/A
Security concernsMinimalMinimalMinimalHigh (code execution risk)

TOML hits the sweet spot for configuration files: it is more expressive than INI, more readable than JSON (which lacks comments), and safer than YAML (which can execute arbitrary code if loaded unsafely). The fact that Python’s packaging ecosystem chose TOML as its standard format tells you a lot about where the community is headed. Now let us learn the format.

Understanding TOML Syntax

Before writing code, it helps to understand what a TOML file looks like. The format is simple — if you can read an INI file, you can read TOML. The main building blocks are key-value pairs, tables (sections), and arrays.

# sample_config.toml
# This is a comment — TOML uses the hash symbol

# Basic key-value pairs (top level)
title = "My Application"
version = 1

# A table (like an INI section)
[owner]
name = "Alice Johnson"
email = "alice@example.com"

# Nested table using dotted notation
[database.primary]
host = "db.example.com"
port = 5432
enabled = true

[database.replica]
host = "replica.example.com"
port = 5433
enabled = false

# Array of tables (list of objects)
[[servers]]
name = "alpha"
ip = "10.0.0.1"
role = "web"

[[servers]]
name = "beta"
ip = "10.0.0.2"
role = "worker"

Let us read this file and see how Python interprets each section. The tomllib.load() function converts the entire TOML file into a nested Python dictionary, preserving all the structure and data types.

# read_syntax.py
import tomllib
from pathlib import Path

# Create the config file
toml_content = '''
title = "My Application"
version = 1

[owner]
name = "Alice Johnson"
email = "alice@example.com"

[database.primary]
host = "db.example.com"
port = 5432
enabled = true

[database.replica]
host = "replica.example.com"
port = 5433
enabled = false

[[servers]]
name = "alpha"
ip = "10.0.0.1"
role = "web"

[[servers]]
name = "beta"
ip = "10.0.0.2"
role = "worker"
'''
Path("app_config.toml").write_text(toml_content)

with open("app_config.toml", "rb") as f:
    config = tomllib.load(f)

# Top-level keys
print(f"Title: {config['title']}")
print(f"Version: {config['version']} (type: {type(config['version']).__name__})")

# Table access
print(f"\nOwner: {config['owner']['name']}")

# Nested tables via dotted keys
print(f"\nPrimary DB: {config['database']['primary']['host']}")
print(f"Replica DB: {config['database']['replica']['host']}")

# Array of tables becomes a list of dicts
print(f"\nServers ({len(config['servers'])}):")
for server in config['servers']:
    print(f"  {server['name']} ({server['ip']}) - {server['role']}")

Output:

Title: My Application
Version: 1 (type: int)

Owner: Alice Johnson

Primary DB: db.example.com
Replica DB: replica.example.com

Servers (2):
  alpha (10.0.0.1) - web
  beta (10.0.0.2) - worker

The key things to notice: [database.primary] in TOML becomes config['database']['primary'] in Python — the dot creates nesting. The [[servers]] syntax (double brackets) creates an array of tables — each repeated [[servers]] block adds another dictionary to a list. And version = 1 became a Python int, not a string. These are the three patterns you will use most often in TOML configuration files.

Debug Dee comparing config scrolls
INI files: everything is a string. TOML files: everything is what it should be. Not a hard choice.

Reading TOML Files With tomllib

The tomllib module (Python 3.11+) provides two functions: load() for reading from a file object and loads() for parsing a string. Both return a Python dictionary. The file must be opened in binary mode ("rb") because TOML files are always UTF-8, and tomllib handles the decoding internally to avoid encoding issues.

# reading_toml.py
import tomllib

# Method 1: Load from a file
with open("config.toml", "rb") as f:
    config = tomllib.load(f)
print(f"From file: {config['app']['name']}")

# Method 2: Parse from a string
toml_string = '''
[server]
host = "0.0.0.0"
port = 8080
workers = 4
'''
config_from_string = tomllib.loads(toml_string)
print(f"From string: {config_from_string['server']['host']}:{config_from_string['server']['port']}")

# The result is a regular dict — you can use all dict methods
print(f"\nTop-level keys: {list(config_from_string.keys())}")
print(f"Server config: {dict(config_from_string['server'])}")

Output:

From file: MyWebApp
From string: 0.0.0.0:8080

Top-level keys: ['server']
Server config: {'host': '0.0.0.0', 'port': 8080, 'workers': 4}

One gotcha to watch out for: tomllib.load() requires binary mode ("rb"), not text mode ("r"). If you forget, you will get a TypeError. This is by design — it prevents encoding mismatches. The loads() function takes a regular string, which is convenient for testing or when your TOML content comes from an environment variable or API response rather than a file.

TOML Data Types and Python Mapping

One of TOML’s biggest advantages is its rich type system. Every value in a TOML file maps to a specific Python type, and the parser handles the conversion automatically. Let us see each type in action.

# data_types.py
import tomllib
from datetime import datetime, date, time

toml_data = '''
# Strings
name = "Alice"
path = 'C:\Users\alice'  # Single quotes = literal (no escapes)
bio = """
This is a
multi-line string."""

# Numbers
integer_val = 42
negative = -17
float_val = 3.14
scientific = 6.022e23
hex_val = 0xff
bin_val = 0b1010

# Booleans
enabled = true
debug = false

# Dates and times
created = 2025-01-15T10:30:00
birthday = 2025-01-15
alarm = 07:30:00

# Arrays (can be mixed types, but best practice is same type)
ports = [8080, 8081, 8082]
features = ["auth", "logging", "caching"]

# Inline table
point = {x = 10, y = 20}
'''

config = tomllib.loads(toml_data)

# Check the Python types
print(f"name: {config['name']!r} ({type(config['name']).__name__})")
print(f"integer_val: {config['integer_val']} ({type(config['integer_val']).__name__})")
print(f"float_val: {config['float_val']} ({type(config['float_val']).__name__})")
print(f"enabled: {config['enabled']} ({type(config['enabled']).__name__})")
print(f"created: {config['created']} ({type(config['created']).__name__})")
print(f"birthday: {config['birthday']} ({type(config['birthday']).__name__})")
print(f"ports: {config['ports']} ({type(config['ports']).__name__})")
print(f"point: {config['point']} ({type(config['point']).__name__})")
print(f"hex_val: {config['hex_val']} ({type(config['hex_val']).__name__})")
print(f"bio: {config['bio']!r}")

Output:

name: 'Alice' (str)
integer_val: 42 (int)
float_val: 3.14 (float)
enabled: True (bool)
created: 2025-01-15 10:30:00 (datetime)
birthday: 2025-01-15 (date)
ports: [8080, 8081, 8082] (list)
point: {'x': 10, 'y': 20} (dict)
hex_val: 255 (int)
bio: '\nThis is a\nmulti-line string.'

Every TOML type has a clean Python equivalent. Strings become str, integers become int (even hex and binary), floats become float, booleans become bool, dates and times become datetime.date, datetime.time, or datetime.datetime, arrays become list, and tables (including inline tables) become dict. Compare this to INI where port = 5432 gives you the string "5432" and you have to write config.getint('section', 'port') to get a number. TOML eliminates that entire category of boilerplate.

Writing TOML Files With tomli_w

The standard library only handles reading TOML. For writing, the community standard is tomli_w, a small package that converts Python dictionaries back to TOML format. Install it with pip install tomli_w.

# writing_toml.py
import tomli_w
import tomllib

# Build a configuration as a Python dictionary
config = {
    "app": {
        "name": "DataPipeline",
        "version": "1.0.0",
        "debug": False,
    },
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "pipeline_db",
        "pool_size": 10,
    },
    "logging": {
        "level": "INFO",
        "file": "/var/log/pipeline.log",
        "rotate_mb": 50,
    },
    "features": ["retry", "caching", "metrics"],
}

# Write to file
with open("pipeline_config.toml", "wb") as f:
    tomli_w.dump(config, f)

# Write to string (useful for previewing)
toml_string = tomli_w.dumps(config)
print("Generated TOML:\n")
print(toml_string)

# Verify by reading it back
with open("pipeline_config.toml", "rb") as f:
    verified = tomllib.load(f)
print(f"Verified: {verified['app']['name']} v{verified['app']['version']}")
print(f"Features: {verified['features']}")

Output:

Generated TOML:

features = [
    "retry",
    "caching",
    "metrics",
]

[app]
name = "DataPipeline"
version = "1.0.0"
debug = false

[database]
host = "localhost"
port = 5432
name = "pipeline_db"
pool_size = 10

[logging]
level = "INFO"
file = "/var/log/pipeline.log"
rotate_mb = 50

Verified: DataPipeline v1.0.0
Features: ['retry', 'caching', 'metrics']

Like tomllib.load(), the tomli_w.dump() function uses binary mode ("wb"). The dumps() function returns a string, which is handy for logging or previewing. Notice that tomli_w formats the output cleanly with proper indentation and section headers. The round-trip (write then read) produces identical data, so you can safely use this for configuration management tools that need to update config files programmatically.

Loop Larry writing on a scroll
TOML syntax looks friendly until you forget a closing bracket in a nested table. Then it’s personal.

Handling TOML Parsing Errors

When a TOML file has syntax errors, tomllib raises a TOMLDecodeError with a helpful message that includes the line and column number. You should always wrap your config loading in a try-except block so your application fails gracefully with a clear error message instead of a cryptic traceback.

# error_handling.py
import tomllib
import sys

# Example 1: Invalid TOML syntax
bad_toml = '''
[database]
host = "localhost"
port = not_a_number
'''

try:
    config = tomllib.loads(bad_toml)
except tomllib.TOMLDecodeError as e:
    print(f"TOML syntax error: {e}")

# Example 2: Safe config loading function
def load_config(filepath, required_keys=None):
    """Load and validate a TOML configuration file."""
    try:
        with open(filepath, "rb") as f:
            config = tomllib.load(f)
    except FileNotFoundError:
        print(f"Error: Config file '{filepath}' not found.")
        print("Create one from config.example.toml")
        return None
    except tomllib.TOMLDecodeError as e:
        print(f"Error: Invalid TOML in '{filepath}': {e}")
        return None

    # Validate required keys
    if required_keys:
        missing = [k for k in required_keys if k not in config]
        if missing:
            print(f"Error: Missing required sections: {missing}")
            return None

    return config

# Test with a valid file
config = load_config("config.toml", required_keys=["app", "database"])
if config:
    print(f"\nConfig loaded: {config['app']['name']}")
else:
    print("\nFailed to load config")

Output:

TOML syntax error: Invalid value (at line 4, column 8)

Config loaded: MyWebApp

The load_config() function demonstrates the defensive loading pattern you should use in production code. It handles three failure modes: file not found, invalid TOML syntax, and missing required sections. This approach gives users clear, actionable error messages instead of stack traces. You could extend this with schema validation using a library like pydantic if you need to verify value types and ranges as well.

Environment-Specific Configuration

A common pattern in real applications is having different configurations for development, staging, and production. TOML handles this cleanly by using separate tables for each environment, with a shared defaults section.

# env_config.py
import tomllib
from pathlib import Path
import copy

# Create a multi-environment config
config_content = '''
[defaults]
log_level = "INFO"
workers = 2
cache_ttl = 300

[defaults.database]
port = 5432
pool_size = 5

[development]
log_level = "DEBUG"
workers = 1

[development.database]
host = "localhost"
name = "myapp_dev"
pool_size = 2

[production]
log_level = "WARNING"
workers = 8

[production.database]
host = "db.production.internal"
name = "myapp_prod"
pool_size = 20
'''
Path("environments.toml").write_text(config_content)

def get_config(env_name):
    """Load config for a specific environment, merged with defaults."""
    with open("environments.toml", "rb") as f:
        all_configs = tomllib.load(f)

    # Start with defaults
    defaults = all_configs.get("defaults", {})
    config = copy.deepcopy(defaults)

    # Merge environment-specific overrides
    env_overrides = all_configs.get(env_name, {})
    for key, value in env_overrides.items():
        if isinstance(value, dict) and key in config and isinstance(config[key], dict):
            config[key].update(value)  # Merge nested dicts
        else:
            config[key] = value  # Override scalar values

    return config

# Get config for each environment
for env in ["development", "production"]:
    config = get_config(env)
    print(f"\n--- {env.upper()} ---")
    print(f"Log level: {config['log_level']}")
    print(f"Workers: {config['workers']}")
    print(f"DB: {config['database']['host']}:{config['database']['port']}")
    print(f"Pool size: {config['database']['pool_size']}")

Output:


--- DEVELOPMENT ---
Log level: DEBUG
Workers: 1
DB: localhost:5432
Pool size: 2

--- PRODUCTION ---
Log level: WARNING
Workers: 8
DB: db.production.internal:5432
Pool size: 20

The merging function starts with a deep copy of the defaults, then overlays the environment-specific values on top. Nested dictionaries (like database) are merged rather than replaced, so you only need to specify the values that differ from the defaults. Notice that the port value (5432) was inherited from defaults in both environments because neither development nor production overrode it. This pattern keeps your configuration DRY while still allowing full customization per environment.

Cache Katie at a crossroads
One config file per environment. tomllib.load() picks the right one. No more if-else chains.

Real-Life Example: Application Configuration Manager

Let us build a practical configuration system that you can drop into any Python project. It reads from a TOML file, supports defaults, validates required fields, and provides convenient dot-notation access to nested values.

# config_manager.py
import tomllib
import tomli_w
import copy
from pathlib import Path

class AppConfig:
    """A configuration manager backed by TOML files."""

    def __init__(self, filepath="config.toml"):
        self.filepath = Path(filepath)
        self._data = {}
        self._defaults = {
            "app": {"name": "Unnamed", "version": "0.0.0", "debug": False},
            "server": {"host": "127.0.0.1", "port": 8000, "workers": 2},
            "logging": {"level": "INFO", "file": None},
        }

    def load(self):
        """Load config from file, merged with defaults."""
        self._data = copy.deepcopy(self._defaults)
        if self.filepath.exists():
            with open(self.filepath, "rb") as f:
                file_data = tomllib.load(f)
            self._deep_merge(self._data, file_data)
            print(f"Loaded config from {self.filepath}")
        else:
            print(f"No config file found, using defaults")
        return self

    def _deep_merge(self, base, override):
        """Recursively merge override dict into base dict."""
        for key, value in override.items():
            if key in base and isinstance(base[key], dict) and isinstance(value, dict):
                self._deep_merge(base[key], value)
            else:
                base[key] = value

    def get(self, dotted_key, default=None):
        """Access nested values with dot notation: config.get('server.port')."""
        keys = dotted_key.split(".")
        current = self._data
        for key in keys:
            if isinstance(current, dict) and key in current:
                current = current[key]
            else:
                return default
        return current

    def set(self, dotted_key, value):
        """Set a nested value: config.set('server.port', 9000)."""
        keys = dotted_key.split(".")
        current = self._data
        for key in keys[:-1]:
            current = current.setdefault(key, {})
        current[keys[-1]] = value

    def save(self):
        """Write current config back to the TOML file."""
        with open(self.filepath, "wb") as f:
            tomli_w.dump(self._data, f)
        print(f"Config saved to {self.filepath}")

    def show(self):
        """Display the current configuration."""
        print(tomli_w.dumps(self._data))

# --- Demo usage ---
if __name__ == "__main__":
    # Create a sample config file
    sample = """
[app]
name = "TaskTracker"
version = "3.2.1"
debug = true

[server]
host = "0.0.0.0"
port = 5000

[database]
url = "postgresql://localhost/tasks"
pool_size = 10
"""
    Path("config.toml").write_text(sample)

    # Load and use the config manager
    config = AppConfig("config.toml").load()

    print(f"\nApp: {config.get('app.name')} v{config.get('app.version')}")
    print(f"Server: {config.get('server.host')}:{config.get('server.port')}")
    print(f"Workers: {config.get('server.workers')}")  # From defaults
    print(f"Debug: {config.get('app.debug')}")
    print(f"DB: {config.get('database.url')}")
    print(f"Missing key: {config.get('cache.redis', 'not configured')}")

    # Modify and save
    config.set("server.workers", 8)
    config.set("cache.redis", "redis://localhost:6379")
    config.save()

    print("\nUpdated config:")
    config.show()

Output:

Loaded config from config.toml

App: TaskTracker v3.2.1
Server: 0.0.0.0:5000
Workers: 2
Debug: True
DB: postgresql://localhost/tasks
Missing key: not configured
Config saved to config.toml

Updated config:
[app]
name = "TaskTracker"
version = "3.2.1"
debug = true

[server]
host = "0.0.0.0"
port = 5000
workers = 8

[logging]
level = "INFO"

[database]
url = "postgresql://localhost/tasks"
pool_size = 10

[cache]
redis = "redis://localhost:6379"

This AppConfig class gives you a clean, reusable configuration layer. The get() method supports dot-notation for nested access (config.get('server.port')), the set() method creates intermediate dictionaries automatically, and the deep merge ensures that defaults fill in any gaps without overwriting values from the config file. You can extend this with environment variable overrides (reading os.environ and merging on top) or schema validation with pydantic for type checking.

Frequently Asked Questions

What if I am using Python 3.10 or earlier?

Install the tomli package with pip install tomli. It has the exact same API as the built-in tomllib. A common pattern is to use a try/except import: try: import tomllib except ModuleNotFoundError: import tomli as tomllib. This way your code works on both old and new Python versions without changes. Many popular packages like pip itself use this exact pattern.

Why does tomllib only read TOML and not write it?

The Python core developers decided to include only the reading side because there is a clear, agreed-upon way to parse TOML, but writing TOML involves style choices (formatting, ordering, comment preservation) that are harder to standardize. The tomli_w package fills this gap perfectly and is maintained by the same developer who wrote tomli (which became tomllib). Installing one extra package for writing is a small price for having a stable reading implementation in the standard library.

How does pyproject.toml relate to this?

The pyproject.toml file is a TOML file that Python packaging tools use to define project metadata, dependencies, and build configuration. It follows the exact same TOML syntax covered in this article. You read it with tomllib.load() just like any other TOML file. Tools like pip, setuptools, poetry, and hatch all read from pyproject.toml — it replaced the older setup.py and setup.cfg approach for modern Python projects.

Can I preserve comments when modifying a TOML file?

Neither tomllib nor tomli_w preserves comments — when you load a TOML file, comments are discarded, and when you write it back, only the data is included. If comment preservation is important (for example, in a user-facing config file), consider the tomlkit package (pip install tomlkit). It parses TOML while preserving formatting, comments, and whitespace, making it ideal for tools that need to edit config files without disturbing the user’s layout.

Should I use TOML or YAML for my project?

For application configuration files, TOML is generally the better choice. It is simpler, safer (no code execution risk), and has standard library support in Python. YAML is better suited for complex data serialization tasks where you need features like anchors, references, and custom types — but those same features are what make YAML a security risk if you use yaml.load() instead of yaml.safe_load(). The Python community’s adoption of TOML for pyproject.toml is a strong signal that TOML is the preferred format for configuration going forward.

How do I migrate from configparser (INI) to TOML?

The structure is similar enough that migration is usually straightforward. INI sections become TOML tables, and key-value pairs stay the same syntactically. The main changes are: remove the type conversion calls (getint(), getboolean(), etc.) because TOML handles types natively, convert comma-separated values to TOML arrays, and add proper quoting to string values. For nested sections, replace [section:subsection] with [section.subsection]. A typical migration takes less than an hour even for large config files.

Conclusion

You now know how to use TOML for Python configuration files using tomllib (reading) and tomli_w (writing). We covered the TOML syntax and its data types, how to read and parse TOML files, how to write TOML from Python dictionaries, error handling and validation, environment-specific configuration patterns, and a complete configuration manager class you can use in your own projects. TOML gives you the readability of INI with the expressiveness of JSON and the safety that YAML lacks — it is the right default choice for Python configuration files in 2025 and beyond.

Try extending the AppConfig class we built — add environment variable overrides, integrate it with pydantic for schema validation, or build a CLI tool that reads and modifies TOML configs. The patterns you learned here apply to any Python project that needs configuration management.

For the complete TOML specification, visit https://toml.io/. The Python tomllib documentation is at https://docs.python.org/3/library/tomllib.html.