tomllib Module Complexity¶
The tomllib module parses TOML (Tom's Obvious, Minimal Language) into dictionaries, lists and
scalar values. It only reads: there is no writer. The parser is pure Python and makes one pass
over the whole document, which it holds in memory together with the dictionary it builds.
Available in Python 3.11+.
n is the characters in the document, and d is the parts in the longest key path: a table
header's dotted parts plus those of a dotted key under it, so [a.b] followed by c.d = 1 has
four. Hashing one key part is treated as O(1). Ordinary files keep d to a handful, which makes
both parsing functions O(n); the d factor is paid by every key under a deep header and by every
long dotted key.
Complexity Reference¶
Parsing¶
| Operation | Time | Space | Notes |
|---|---|---|---|
tomllib.loads(s, /, *, parse_float=float) |
O(n·d) | O(n·d) | O(n) for ordinary documents; nesting arrays and inline tables adds nothing beyond their characters |
tomllib.load(fp, /, *, parse_float=float) |
O(n·d) | O(n·d) | Reads the whole binary file with one read(), decodes it as UTF-8, then parses it as loads() does; a text-mode file raises TypeError |
parse_float callback |
O(f) per float | O(f) per float | f = one call's cost in time and space; called once per float value with its text as written, never for integers; returning a dict or list raises ValueError |
TOMLDecodeError¶
| Operation | Time | Space | Notes |
|---|---|---|---|
tomllib.TOMLDecodeError(msg, doc, pos) |
O(pos) | O(1) | A ValueError subclass; the message's line and column are counted from the start of the document. Python 3.14+ signature, and the instance keeps doc by reference |
TOMLDecodeError.msg, TOMLDecodeError.doc, TOMLDecodeError.pos, TOMLDecodeError.lineno, TOMLDecodeError.colno |
O(1) | O(1) | Python 3.14+ |
Parsing a Document¶
From a String¶
loads() takes a str and returns a new dictionary. Tables become dictionaries, arrays become
lists, and dates and times become datetime objects.
import datetime
import tomllib
document = """
title = "example"
ports = [8001, 8002]
created = 2024-01-16T10:30:00Z
[database]
server = "192.168.1.1"
enabled = true
[[users]]
name = "Alice"
[[users]]
name = "Bob"
"""
config = tomllib.loads(document) # O(n·d), O(n) here
assert config['database'] == {'server': '192.168.1.1', 'enabled': True}
assert config['ports'] == [8001, 8002]
assert [user['name'] for user in config['users']] == ['Alice', 'Bob']
assert config['created'] == datetime.datetime(
2024, 1, 16, 10, 30, tzinfo=datetime.timezone.utc
)
From a File¶
load() needs a file opened in binary mode, open(path, "rb"), because TOML is always UTF-8.
It reads the whole file at once, so the bytes, the decoded text and the result are all in memory
while it parses.
import io
import tomllib
data = b'name = "myapp"\nversion = "1.0.0"\n'
config = tomllib.load(io.BytesIO(data)) # O(n·d): one read(), then loads()
assert config == {'name': 'myapp', 'version': '1.0.0'}
try:
tomllib.load(io.StringIO('name = "myapp"'))
except TypeError as error:
assert 'binary mode' in str(error)
else:
raise AssertionError('a text-mode file was accepted')
Key Depth¶
Every key/value pair checks its whole path, header included, against the tables already
declared. A key under [a.b.c] therefore pays for three parts before its own, and a dotted key
with p parts pays O(p²). Shallow headers and short keys keep each pair O(1) beyond its
characters.
import tomllib
# The same dictionary, spelled with a header or with dotted keys
headed = tomllib.loads("[server.http]\nport = 80\nhost = 'a'\n") # d = 3
dotted = tomllib.loads("server.http.port = 80\nserver.http.host = 'a'\n") # d = 3
assert headed == dotted == {'server': {'http': {'port': 80, 'host': 'a'}}}
# A table cannot be declared twice, which is what the path checks enforce
try:
tomllib.loads("[server]\nport = 80\n[server]\nhost = 'a'\n")
except tomllib.TOMLDecodeError as error:
assert 'Cannot declare' in str(error)
else:
raise AssertionError('a table was declared twice')
Nesting arrays and inline tables costs only their characters. They are parsed recursively,
though, so nesting far enough to exhaust the recursion limit raises RecursionError.
import tomllib
nested = tomllib.loads("k = [[1, 2], {a = {b = [3]}}]") # O(n)
assert nested == {'k': [[1, 2], {'a': {'b': [3]}}]}
too_deep = "k = " + "[" * 5000 + "]" * 5000
try:
tomllib.loads(too_deep)
except RecursionError:
pass
else:
raise AssertionError('5,000 nested arrays were parsed')
Exact Floats with parse_float¶
parse_float receives each float's text, underscores included, so decimal.Decimal keeps every
digit. It is called once per float and never for an integer, so its cost is added per float
value.
import decimal
import tomllib
calls = []
def exact(text):
calls.append(text)
return decimal.Decimal(text)
config = tomllib.loads(
"price = 1_000.10\nrates = [0.1, 2e3, inf]\ncount = 3\n",
parse_float=exact,
) # O(n·d) plus one parse_float call per float
assert config['price'] == decimal.Decimal('1000.10')
assert config['rates'][0] == decimal.Decimal('0.1')
assert config['count'] == 3 # integers never reach parse_float
assert calls == ['1_000.10', '0.1', '2e3', 'inf']
Handling Errors¶
A document that is not valid TOML raises TOMLDecodeError, a ValueError, with the position
of the problem in its message. From Python 3.14 the same position is also available as attributes.
import sys
import tomllib
document = "name = 'myapp'\nversion = \n"
try:
tomllib.loads(document)
except tomllib.TOMLDecodeError as error:
assert isinstance(error, ValueError)
assert 'line 2, column 11' in str(error)
if sys.version_info >= (3, 14):
assert (error.lineno, error.colno, error.msg) == (2, 11, 'Invalid value')
assert error.doc[error.pos] == '\n'
else:
raise AssertionError('an invalid document was parsed')
Common Patterns¶
Merging Over Defaults¶
The result is a plain dictionary, so defaults merge with ordinary dictionary operations. The merge is shallow: a table in the document replaces the whole default table.
import tomllib
defaults = {'port': 8000, 'debug': False, 'log': {'level': 'info', 'file': None}}
document = "debug = true\n[log]\nlevel = 'debug'\n"
config = {**defaults, **tomllib.loads(document)} # O(n·d) + O(keys)
assert config['port'] == 8000
assert config['debug'] is True
assert config['log'] == {'level': 'debug'} # 'file' went with the replaced table
Performance Best Practices¶
✅ Do:
- Open files with
"rb"and pass them toload(), or decode yourself and callloads() - Keep table headers shallow and dotted keys short, so
dstays a small constant - Pass
parse_float=decimal.Decimalwhen floats must be exact; it costs one call per float
❌ Avoid:
- Very long dotted keys or deep headers in generated files: each key pays for its whole path
- Parsing untrusted input without catching
RecursionErroras well asTOMLDecodeError - Re-parsing the same file on every access; parse once and keep the dictionary
Version Notes¶
- Python 3.11+:
tomllibadded - Python 3.11.16, 3.12.14, 3.13.14, 3.14.6+: a key or table header with more dotted parts
than the recursion limit in force when
tomllibwas imported raisesRecursionError - Python 3.14+:
TOMLDecodeErrortakesmsg,docandpos, and exposes them withlinenoandcolno; any other arguments are deprecated
Related Modules¶
- json - the same shape of result from a JSON document
- configparser - INI files, with every value a string
- decimal - exact floats through
parse_float