pyproject.toml¶
Level: 101 → 201 · for Python programmers
One line: One declarative file says what a project is called, what it needs, and how each of its tools should behave — and because it is declarative, anything can read it, including your editor, including you, with tomllib and no installed dependency at all.
Open a Python project in a modern editor and it may offer to derive the project structure and dependencies from pyproject.toml. That offer is only possible because of what this file replaced. A setup.py was a program: to find out what a package needed, you had to execute somebody's Python. pyproject.toml is data, and the whole ecosystem shifted the moment it became so — an editor, a linter, a build backend and a CI runner can all read the same file and agree about the answer without running anything.
There are two kinds of table in it, and the difference is the only structural rule worth memorising. [project], [build-system] and [dependency-groups] are standardised — PEP 621 ↗ and PEP 735 ↗ — so every tool reads them identically. Everything under [tool.<name>] is that tool's own room: [tool.pytest.ini_options] is pytest's business and no packaging standard has an opinion about it. That is what lets one file serve a dozen tools without a committee.
The program below parses an embedded pyproject.toml and takes it apart. Nothing is read from disk, so it prints the same thing everywhere.
Verified output of pyproject_toml_py.py — regenerated by tools/run_examples.py, never hand-typed.
READING pyproject.toml WITH THE STANDARD LIBRARY
tomllib has shipped with Python since 3.11. No install, no dependency.
1. THE FILE HAS TWO KINDS OF TABLE, AND ONLY ONE IS STANDARDISED
[project] standard, PEP 621 -- every tool reads it the same way
[dependency-groups] standard, PEP 735 -- same, and it is the newest one
[tool] each tool's own room, and nobody else looks inside
Anything under [tool.X] belongs to X alone: [tool.pytest] is not
part of the packaging standard and pytest is the only reader.
That is the whole convention -- one file, one room per tool.
2. WHAT THE PROJECT SAYS ABOUT ITSELF
name 'star-voting-library'
version '0.1.0'
requires-python '>=3.10,<3.14'
dependencies 2 of them:
pref-voting>=1.18
pyyaml>=6.0.3
3. A NESTED TABLE IS JUST A NESTED DICT
A dotted header is nothing but nesting: the table [tool.uv.workspace]
is three dict lookups, and a key inside it is a fourth.
data['tool']['uv']['workspace'] -> {'members': ['engine']}
data['tool']['uv']['workspace']['members'] -> ['engine']
data['tool']['uv']['sources'] -> {'starvote': {'workspace': True, 'editable': True}}
data['tool']['pytest']['ini_options'] -> {'testpaths': ['tests']}
Those two uv tables are the whole of a WORKSPACE. 'members' says
this repository holds other packages, each with its own
pyproject.toml, that share one resolved environment. 'sources'
says the name starvote resolves to the copy in this repository
and not to the one on PyPI -- editable, so an edit is live.
That is what an editor offers to read when it proposes deriving
the project layout from pyproject.toml: the answer is in the
file, so it no longer has to guess which folders are packages.
4. THREE THINGS ARE CALLED DEPENDENCIES AND THEY ARE NOT THE SAME
[project].dependencies
who needs them: a USER of this code, always, on install
pref-voting>=1.18
pyyaml>=6.0.3
[project.optional-dependencies]
who needs them: a USER who asks -- pip install 'star-voting-library[plotting]'
plotting matplotlib>=3.8
[dependency-groups]
who needs them: a DEVELOPER of this code, and nobody else ever
dev pytest>=8.0, mypy>=2.0.0
docs mkdocs-material>=9.5, mkdocs-same-dir==0.1.3
Only the third one is invisible to whoever installs the package.
pytest is not a dependency of the library; it is a dependency of
working on the library, and PEP 735 exists to say so in the file
rather than in a requirements-dev.txt nothing validates.
5. THE VALUES ARRIVE AS PYTHON TYPES, AND THE QUOTES DECIDE WHICH
v = "1.10" -> '1.10' str
v = 1.10 -> 1.1 float
v = true -> True bool
v = 2026-09-06 -> datetime.date(2026, 9, 6) date
v = ["a", "b"] -> ['a', 'b'] list
No bare word is a boolean: TOML has only true and false, lower
case. There is nothing here for a Yes or an Off to be coerced
into, which is the one thing TOML buys over YAML for a config file.
6. tomllib WANTS BYTES -- THIS IS CHAPTER 1 AGAIN
TOML is DEFINED to be UTF-8, so tomllib decodes the bytes itself
rather than trusting whatever open() would have guessed.
tomllib.load(binary_file) OK -- 3 top-level tables
tomllib.loads(str) OK -- 3 top-level tables
tomllib.load(text_file) TypeError
File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`
tomllib.loads(bytes) TypeError
(message deliberately not shown -- see below)
load() takes a file opened 'rb'; loads() takes a str. Getting it
backwards is a TypeError, not a mojibake bug -- which is the point
of refusing to guess.
The last message is withheld because it is not stable. Up to 3.13
the bytes reached str.replace() and CPython reported that; 3.14
catches it and says so in tomllib's own words. Same type, same
bug, different sentence. AN EXCEPTION MESSAGE IS NOT API: catch
the type, and never match on the text.
7. IT ONLY READS
There is no tomllib.dump(). Writing TOML is not in the standard
library, on purpose: reading config is everyone's problem, and
writing it -- preserving comments and layout -- is a hard one.
Edit pyproject.toml by hand, or let your tool do it.
What the run shows¶
A workspace is one line, and it is not a Python standard. [tool.uv.workspace] sits in uv's room, which means "workspace" is a tool's concept, not the language's — uv ↗ borrowed it from Cargo, and Poetry and PDM have their own arrangements. What it declares is that one repository holds several packages, each with its own pyproject.toml, that should share a single resolved environment. [tool.uv.sources] then pins a name to a sibling rather than to PyPI, so import starvote picks up the copy in the repo you are editing. An editor offering to derive the project from these files is offering to stop guessing which folders are packages and read the answer instead — and the answer is genuinely there, three dict lookups down.
Three different things are called dependencies. [project].dependencies are what a user of your code must have; [project.optional-dependencies] are extras a user can ask for by name; [dependency-groups] are what a developer of your code needs and nobody else ever installs. Only the last of the three is invisible to whoever installs the package — pytest is not a dependency of the library, it is a dependency of working on the library. Before PEP 735 that distinction lived in a requirements-dev.txt that no tool validated and no lockfile covered.
tomllib wants bytes, and that is chapter 1 turning up in a config parser. TOML is defined to be UTF-8, so tomllib decodes the bytes itself rather than trusting whatever open() would have guessed on the machine it happens to be running on. load() therefore takes a file opened "rb" and loads() takes a str; getting them backwards is a loud TypeError at the door instead of a quiet mojibake bug three layers in. It is the same design decision as str is not bytes, applied to a file format.
And one message is deliberately missing from that output. Passing bytes to loads() fails on every version, but the sentence changed: up to 3.13 the bytes reached str.replace() and CPython reported that, while 3.14 catches the case and answers in tomllib's own words. Printing it would have produced an answer key that passes on one interpreter and fails on another — the class of mistake this library records keys to prevent. The general rule is worth more than the detail: an exception message is not API. Catch the type. Never match on the text.
What the file does not do is create an environment. requires-python = ">=3.10,<3.14" is a constraint — a statement about which interpreters this project is willing to run on, checked by an installer. It does not select one, and it does not build a .venv. That is a separate step (uv sync, python -m venv, whatever your tool is), and an editor showing <No interpreter> in its status bar is telling you that step is missing or that it has not been pointed at the result. The file describes; a tool builds; the editor is shown. Three jobs, and only the first one lives here.
If you are coming from Rust¶
Cargo.toml is the closest analogue in any language, and the resemblance is not an accident — TOML came out of that world, and uv's workspaces are Cargo's workspaces with the names changed. [package] maps to [project], [dependencies] to [project].dependencies, [dev-dependencies] to [dependency-groups].dev, [features] to [project.optional-dependencies], and [workspace] members to [tool.uv.workspace] members almost word for word. Two real differences: Cargo is the build system, so there is nothing corresponding to [build-system] and no question of which backend runs; and Cargo.lock is produced by the one tool everybody uses, whereas a Python lockfile belongs to whichever tool you chose. See Cargo and its dependencies ↗ and the lockfile ↗ in the Rust library.
If you are coming from ABAP¶
There is no equivalent, and noticing why is the useful part. In ABAP the project is not a file in a repository — it is the package hierarchy inside the system, and dependencies are enforced at design time by package interfaces and use accesses rather than declared in a manifest a tool resolves. There is no version range to satisfy, because there is one system with one set of installed software components; "resolving dependencies" is an upgrade, not a command you run. The nearest thing to a per-repository manifest arrived with abapGit's .abapgit.xml, which states the format and the starting folder for a repository — a fraction of what pyproject.toml carries, because the rest of it has nowhere to point. (Not machine-checked — CI cannot run ABAP.)
Try it¶
- Read your own project's file:
python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project'])". Now drop the'rb'and read the error message carefully — it names the fix. - Ask a
pyproject.tomla question it was not written to answer. Print every[tool.X]table that is present, sorted, with a count of the keys in each: that is the list of tools this project is actually configured for, and it is usually longer than anyone remembers. - Put
version = 1.10in a scratch TOML file and parse it, thenversion = "1.10". One is afloatthat is no longer the string you typed. Compare with what YAML does to the same two lines — and to a bareNo— which is why a great many config files moved.
See also¶
- Opening a file — what
open()guesses, and whytomllibrefuses to stris notbytes— the same boundary, one chapter earlier- The TOML specification ↗ — the format itself, short enough to read in one sitting
- The
pyproject.tomlspecification ↗ — every standard key, with the versions each arrived in