INI vs TOML: what Python's packaging actually fixed

Python spent a decade moving from setup.py to setup.cfg to pyproject.toml. Following that move is the clearest way to see what INI can and cannot express — and where TOML is the wrong trade.

Somebody hands you a package and asks what it depends on. If the answer lives in setup.py, there is no way to read it — you have to run it. That single sentence is the reason Python's packaging config moved twice in a decade, and following that move is the clearest way I know to see what an INI file can and cannot say.

This is not the usual syntax comparison. Both formats have sections in square brackets and key = value lines, and if you stopped at appearances you would conclude they are the same thing with different punctuation. The difference is what a parser hands back, and Python's own history is a worked example of it.

The catch-22 that started it

A setup.py is a program. Its dependency list is whatever the code puts there, computed while the file runs, which means the only way for pip to find out is to execute it first.

setup.py — this looks completely ordinary
import subprocess, sys
from setuptools import setup

# Perfectly reasonable-looking: pick deps based on the platform.
extra = ["pywin32"] if sys.platform == "win32" else []
print("side effect: this ran on your machine", file=sys.stderr)
subprocess.run(["id"], check=False)

setup(name="subscriber-sync", version="1.20",
      install_requires=["httpx>=0.27"] + extra)
Asking it a read-only question
$ python3 setup.py --name
side effect: this ran on your machine
uid=0(root) gid=0(root) groups=0(root)
subscriber-sync

I asked for the name. I got a shell command executed as root and then the name. The subprocess call is deliberately obvious here, but the platform check above it is the kind of thing you would find in a real package and never look at twice.

PEP 518 puts the problem in one sentence, and its complaint is about bootstrapping rather than security: "No tooling (besides setuptools itself) can access this information without executing the setup.py, but setup.py can't be executed without having these items installed." You need the build dependencies to read the list of build dependencies. Everything else — including the run above — falls out of that.

setup.cfg fixed the execution, and INI came with it

The first fix was to move the metadata out of the code. setuptools grew declarative configuration in setup.cfg, and the same package becomes a file with nothing to execute.

setup.cfg
[metadata]
name = subscriber-sync
version = 1.20
keywords = telecom, provisioning

[options]
python_requires = >;=3.11
install_requires =
    httpx>=0.27
    smol-toml

[options.extras_require]
dev =
    pytest

That is real progress and it is worth saying so — a tool can now answer "what does this need" by reading bytes. But setup.cfg is INI, parsed by configparser, and here is everything that file contains as far as the parser is concerned:

Python 3.11
>>> c = configparser.ConfigParser(); c.read("setup.cfg")
>>> for k, v in c["options"].items(): print(repr(k), "->", repr(v))
'python_requires' -> '>=3.11'
'install_requires' -> '\nhttpx>=0.27\nsmol-toml'

>>> c["metadata"]["keywords"]
'telecom, provisioning'

Two strings. The dependency list is one string with newlines in it, and the keyword list is one string with a comma in it. Nothing in the file said "list" because INI has no way to say it. Everything that comes out of configparser is str — there is no integer, no boolean, no array and no date, and getint/getboolean exist precisely because the caller has to supply the type the format could not carry.

The same config in TOML

PEP 621 standardised the [project] table so the same metadata has one spelling across build back-ends. Here is the file above rewritten, with a tool section added so there is something nested to look at.

pyproject.toml
[project]
name = "subscriber-sync"
version = "1.20"
keywords = ["telecom", "provisioning"]
requires-python = ">=3.11"
dependencies = ["httpx>=0.27", "smol-toml"]

[project.optional-dependencies]
dev = ["pytest"]

[tool.provisioning]
port = 5432
enabled = true
reviewed = 2026-01-14

[tool.provisioning.retry]
attempts = 3
backoff-seconds = 1.5
Read back with tomllib, which ships with Python 3.11
>>> d = tomllib.load(open("pyproject.toml", "rb"))
>>> d["project"]["dependencies"]
['httpx>=0.27', 'smol-toml']                    # list, not a string

>>> p = d["tool"]["provisioning"]
>>> p["port"], p["enabled"], p["reviewed"]
(5432, True, datetime.date(2026, 1, 14))        # int, bool, date

>>> p["retry"]
{'attempts': 3, 'backoff-seconds': 1.5}         # a dict, one level down

Same information, and no post-processing. The list is a list, the port is an integer, the date is a datetime.date, and [tool.provisioning.retry] arrived as a nested dictionary. That is the whole capability gap in one console session.

What you want to storeTOMLINIWhat people write instead
Integerport = 5432intstring onlygetint() at every call site
Booleanenabled = trueboolstring onlygetboolean(), which accepts yes/on/1
Listdeps = ["a", "b"]no such thingnewline- or comma-separated string
Nested table[tool.x.retry] → dict in a dictno such thinga dot in the section name
Date2026-01-14datestring onlystrptime in the reader

Each workaround is a convention, not a rule

The right-hand column is the interesting one, because those workarounds do work. They are used in production by projects you depend on. What they are not is enforced — the format has no opinion about them, so the agreement lives in the reader's code and nowhere else.

The list that is really a string

PEP 518's own comparison shows an INI version of the build requirements, and it uses the newline form: a key set to nothing, then indented continuation lines. setuptools reads it the way you would expect, by splitting on newlines and dropping the blanks. The comma form is just as common — keywords above uses it.

The two conventions, and the value that breaks one of them
>>> raw = c["options"]["install_requires"]
>>> [line for line in raw.splitlines() if line.strip()]
['httpx>=0.27', 'smol-toml']                    # newline convention: fine

>>> [s.strip() for s in c["metadata"]["keywords"].split(",")]
['telecom', 'provisioning']                     # comma convention: fine

>>> c2["metadata"]["author"]                    # author = Lovelace, Ada
'Lovelace, Ada'
>>> [s.strip() for s in c2["metadata"]["author"].split(",")]
['Lovelace', 'Ada']                             # one author became two

Nothing failed. A single author named in surname-first order came back as two people, because the comma in the value is indistinguishable from the comma doing the separating. There is no escaping rule to reach for — INI has no quoting story that configparser honours here, so the only defence is knowing which fields are lists and never putting a comma in the others.

The nesting that is really a dot

[tool.black] and [tool.isort] look like two children of a tool section. To configparser they are two sections whose names happen to contain a full stop.

tool-sections.ini
[tool.black]
line-length = 88

[tool.isort]
profile = black
What the parser thinks it read
>>> c.sections()
['tool.black', 'tool.isort']
>>> "tool" in c
False

Flat. To walk it as a tree you write the splitting yourself, and you decide what happens when someone also defines a plain [tool] section. TOML does not leave that to you: [tool.provisioning.retry] is defined by the TOML specification's table rules to create the intermediate tables, and redefining one is an error rather than a merge.

The one place INI comes out ahead

version = 1.20 survives INI intact. It comes back as the string '1.20', trailing zero and all, because there is nothing in the format that would make it a number. Write the same line unquoted in TOML and you get the float 1.2 — the release you pinned no longer exists.

That is not a defect in TOML so much as the price of having types at all, and every typed config format loses that trailing zero the same way. It is worth noticing, though, because it is the one case where "everything is a string" is the answer you wanted.

What TOML costs

A stricter format rejects more files, and a hand-edited TOML file gets rejected for things INI would have shrugged at. All five of these parse without complaint as INI:

tomllib on the left, configparser on the right
name = subscriber-sync
  TOML  TOMLDecodeError: Invalid value (at line 1, column 8)
  INI   'subscriber-sync'

line length = 88
  TOML  TOMLDecodeError: Expected '=' after a key in a key/value pair
  INI   'line length' -> '88'

area = 007
  TOML  TOMLDecodeError: Expected newline or end of document after a statement
  INI   '007'

deps = ["httpx", "smol-toml",,]
  TOML  TOMLDecodeError: Invalid value (at line 1, column 30)

name = "subscriber-sync
  TOML  TOMLDecodeError: Illegal character '\n' (at line 1, column 24)

The first one is the one that catches everybody. An unquoted string is the most natural thing in the world to type after years of INI, and TOML will not take it. The second is close behind — a key with a space in it needs quoting, which rules out the loose Some Setting = value style that Windows-era config files are full of.

There is a smaller cost worth knowing about if you generate config rather than write it. tomllib is read-only — load and loads, and no dump — so writing TOML from Python means adding a third-party dependency, while configparser has written INI back out since forever. For a build tool trying to stay self-contained, that asymmetry is not nothing.

And INI is still the format more things can read. A shell script with grep and cut gets useful answers out of an INI file; the same script meets a TOML array spread over four lines and stops being a one-liner.

When INI is still the right pick

Notice what did not happen while Python was moving: php.ini, .gitconfig, systemd unit files, .editorconfig and my.cnf all stayed exactly where they were. That is not inertia. Those files are flat, small, and every value in them genuinely is a string or a number that the reader was always going to coerce anyway.

A test I have found reliable: go through your config and count the values whose type you actually care about at read time. If the answer is nearly zero, TOML is selling you types you were not using, and you pay for them in stricter parsing and a heavier dependency.

The moment to switch is when you catch yourself writing one of the workarounds. A split(",") in the config loader, a section name you are splitting on dots, a strptime on a value read out of a config file — each of those is the format telling you it has run out of room. Python hit all three at once, which is why the packaging guide now starts at pyproject.toml rather than describing it as an upgrade.

One thing that is not a reason on its own: comments. Both formats keep them, and both lose them through a parse-and-write round trip in most libraries. If comments are the argument, you are arguing about your tooling rather than your format.

Moving a file across

There is no direct INI-to-TOML converter here, and that is deliberate — the interesting step is deciding which strings become which types, and a converter that guessed would be making that decision for you. JSON is the useful staging post, because it shows you exactly what the INI parser saw before anything is retyped.

INI to JSON Step one: see the structure your INI file actually produces, with every value still a string, so nothing has been guessed yet. JSON to TOML Step two, after you have typed the values you care about — nested objects become [sections] and arrays become real TOML arrays.

Going the other way, TOML to JSON flattens a pyproject.toml so you can diff two of them properly, and JSON to INI handles the case where a service you cannot change still wants an INI file. If a file will not parse at all, INI Validator and TOML Validator give you a line number instead of a traceback.

Where the line actually falls

PEP 518 chose TOML because it is, in its own words, "human-usable (unlike JSON), it is flexible enough (unlike configparser), stems from a standard (also unlike configparser), and it is not overly complex (unlike YAML)". Two of those four clauses are about INI, and they are the two this article has been demonstrating.

  • INI stores text. Integers, booleans, lists, nesting and dates are all things the reader adds afterwards.
  • The workarounds are agreements between humans. A comma-separated list breaks on a value containing a comma, and nothing warns you.
  • TOML stores values. A list is a list to every parser, which is the entire point of standardising [project].
  • Strictness is the bill for that. Unquoted strings and keys with spaces are the two rejections you will hit first.
  • Flat and small is INI territory, and the config files that stayed on INI stayed for good reasons.

If you take one habit from this: before proposing a format change, open the config and count how many values the reader has to coerce. That number decides it faster than any argument about elegance, and it is the number Python was staring at for ten years.