If you opened a Python project five years ago, you’d be greeted by a cheerful little zoo: setup.py, setup.cfg, MANIFEST.in, requirements.txt, requirements-dev.txt, maybe a tox.ini, and — if the maintainer was particularly thorough — a .flake8 and a pytest.ini for good measure. Each one had its own format, its own quirks, and its own opinions about how to spell “dependency.”
In 2026 you mostly need one file: pyproject.toml. This lesson is the tour.
A short history (so the present makes sense)
Python’s original packaging tool was setuptools, configured via a setup.py script. The problem is right there in the name — it’s a script. To learn anything about a package (its name, its dependencies, its version), you had to execute arbitrary Python. That’s both a security headache and an introspection nightmare. Want a tool to read a project’s metadata? Better hope nobody put import requests at the top of their setup.py.
setup.cfg came along as a declarative bolt-on — same metadata, but in INI format so tools could parse it without running anything. Better, but now you had two files describing the same thing, and setup.py was still required as a stub.
Then came two PEPs that quietly fixed everything:
- PEP 518 (2016) introduced
pyproject.tomland the[build-system]table. Finally, a way to say “this project is built with X” without running Python code first. - PEP 621 (2020) added the
[project]table — a standard, tool-agnostic place for metadata: name, version, dependencies, authors, the works.
By 2024 every modern build backend supported PEP 621, and setup.py slid quietly into the legacy bucket. You’ll still see it in older projects (and you can still write one if you really want), but for anything new in 2026, the answer is pyproject.toml.
The three kinds of tables
A pyproject.toml has roughly three kinds of sections:
[build-system]— how to build the package. Required if you’re publishing.[project]— the standardised metadata about your package.[tool.*]— tool-specific configuration. Anything namespaced undertool.is fair game for individual tools to claim. Ruff lives at[tool.ruff], pytest at[tool.pytest.ini_options], mypy at[tool.mypy], and so on.
Let’s walk through a realistic file.
A real-ish pyproject.toml
[build-system]
requires = ["hatchling>=1.18"]
build-backend = "hatchling.build"
[project]
name = "weather-cli"
version = "0.3.1"
description = "A tiny CLI that fetches the weather for a city."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
{ name = "Narcis Miclaus", email = "hello@example.com" }
]
keywords = ["weather", "cli", "openmeteo"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3.13",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"httpx>=0.27",
"click>=8.1",
"rich>=13.7",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov",
"ruff>=0.4",
"mypy>=1.10",
]
[project.scripts]
weather = "weather_cli.__main__:main"
[project.urls]
Homepage = "https://github.com/example/weather-cli"
Issues = "https://github.com/example/weather-cli/issues"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
[tool.mypy]
python_version = "3.11"
strict = true
Section by section:
[build-system]. Says “to build me, install hatchling and call hatchling.build.” Build frontends like pip, uv, and build read this and obey. You’ll never invoke the backend directly.
[project]. The good stuff. name and version are required. requires-python matters more than you’d think — it stops users on older interpreters from accidentally installing a package that won’t work, and it tells tools like uv which Python to fetch.
dependencies is a list of PEP 508 strings. The classic format: package>=1.2,<2.0; python_version >= "3.10". You can pin loosely (httpx>=0.27), tightly (httpx==0.27.2), or with markers (pywin32; sys_platform == "win32").
optional-dependencies are extras. pip install weather-cli[dev] installs the dev group. Common groups: dev, test, docs. (Note: PEP 735’s dependency-groups table is gaining traction in 2026 for non-published dev dependencies — it’s the same idea but doesn’t pollute your published wheel’s metadata. Worth knowing about.)
project.scripts generates console entry points. After pip install weather-cli, the user gets a weather command on their PATH that calls main() from weather_cli.__main__. No more chmod +x shell scripts.
project.urls shows up on PyPI as those handy sidebar links.
[tool.*] sections are wherever your tools want them. Ruff, pytest, mypy, coverage, hatch — they all read from pyproject.toml now. One file. One source of truth.
Build backends: who actually builds the wheel?
The [build-system] table picks a build backend. There are four you’ll encounter in the wild:
hatchling — from the Hatch project. Fast, simple, sane defaults, no magic. In 2026 this is the recommended starter choice and what uv init picks for you. If you’re starting fresh, use this.
setuptools — the old guard. Still ubiquitous because most existing packages use it. Works fine, but the configuration surface is huge and full of legacy quirks (e.g. package_dir, find_packages, namespace package weirdness). Pick this if you’re maintaining something that already uses it.
poetry-core — the build backend behind Poetry. Opinionated; assumes you’re using Poetry’s whole workflow. If you’re not committing to Poetry as your manager, pick something else.
flit-core — minimal. Pure-Python packages only, no C extensions, no fancy build steps. If your package is a single directory of .py files, flit is delightful.
There are others — pdm-backend, maturin for Rust extensions, scikit-build-core for C++/CMake projects — but those four cover the bulk of pure-Python work.
Why does uv recommend hatchling? Speed and predictability. Hatchling has no implicit “discover everything” mode that surprises you at build time, its config maps almost 1:1 to PEP 621, and it builds wheels in milliseconds. There’s no good reason to pick anything else for a new pure-Python project.
Converting an old setup.py to pyproject.toml
Suppose you inherit this:
# setup.py
from setuptools import setup, find_packages
setup(
name="oldthing",
version="1.2.0",
description="An old thing.",
author="Someone",
author_email="someone@example.com",
packages=find_packages(),
install_requires=[
"requests>=2.28",
"pyyaml",
],
extras_require={
"dev": ["pytest", "black"],
},
entry_points={
"console_scripts": [
"oldthing=oldthing.cli:main",
],
},
python_requires=">=3.9",
)
The translation is mostly mechanical:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "oldthing"
version = "1.2.0"
description = "An old thing."
requires-python = ">=3.9"
authors = [{ name = "Someone", email = "someone@example.com" }]
dependencies = [
"requests>=2.28",
"pyyaml",
]
[project.optional-dependencies]
dev = ["pytest", "black"]
[project.scripts]
oldthing = "oldthing.cli:main"
Then delete setup.py. Hatchling will auto-discover packages under src/ or in the project root; if your layout is unusual, you add a [tool.hatch.build.targets.wheel] table to point at it. Run python -m build (or better, uv build) and you’ll get an identical wheel out the other side.
Two gotchas when migrating:
- Dynamic versions. If your old
setup.pyread the version from__init__.pyor a Git tag, you needdynamic = ["version"]in[project]and a corresponding[tool.hatch.version]block telling hatchling where to look. MANIFEST.in. The old way of including non-Python files in source distributions. Hatchling has its own[tool.hatch.build]configuration for this; most of the time the defaults already do the right thing.
TOML in 30 seconds
If you’ve never touched TOML before: it’s an INI-shaped format with proper types. Strings in double quotes, arrays in brackets, tables in [square.brackets], sub-tables under dotted names. The Python standard library has parsed it natively since 3.11 (import tomllib), so any tool that wants to read your config can do it without an extra dependency.
The only TOML pitfall worth flagging: arrays of tables use double brackets:
[[project.authors]]
name = "Alice"
email = "alice@example.com"
[[project.authors]]
name = "Bob"
email = "bob@example.com"
PEP 621 lets you write that more compactly with inline tables (authors = [{ name = "Alice", email = "..." }, ...]), which is what most projects do. Both forms are valid; pick whichever reads better.
What pyproject.toml isn’t
It’s not a lockfile. The dependencies list says “I want httpx >= 0.27” — it doesn’t say “specifically httpx 0.27.2 with these exact transitive dependencies.” That’s what a lockfile is for, and your package manager generates it separately (uv.lock, poetry.lock, the compiled output of pip-tools).
It’s also not magic. Tools have to agree to read it. A linter that doesn’t support [tool.<name>] won’t suddenly start because you added the table. Most modern Python tools do; some old ones (looking at you, flake8) needed plugins to catch up.
Common mistakes
A few things that catch people on their first pyproject.toml:
- Pinning too tight in libraries. If you’re publishing a library,
dependencies = ["httpx==0.27.2"]is hostile to your users — anyone who pins a different version of httpx can’t install your package alongside it. Use ranges (>=0.27,<0.28or just>=0.27). Tight pins belong in application projects, where the lockfile handles reproducibility. - Forgetting
requires-python. Without it, your package installs on Python 2.7 and prints cryptic syntax errors. Always set a sensible floor. - Mixing tabs and spaces in TOML. TOML is whitespace-tolerant for indentation, but mixed whitespace inside arrays and inline tables produces parser errors that look weird (“Expected ’=’ after a key in a key/value pair”). Stick to spaces.
- Putting comments mid-array. TOML allows
# commentson their own lines or at line ends, but a comment between two array items has to be on its own line —["a", # nope, "b"]parses but eats the second element.
Reading pyproject.toml from Python
Sometimes you want to read the file yourself — to display the package version, generate documentation, or wire up some custom tooling. Python ships with a TOML parser since 3.11:
import tomllib
from pathlib import Path
data = tomllib.loads(Path("pyproject.toml").read_text())
print(data["project"]["name"])
print(data["project"]["dependencies"])
For older Python versions, install tomli (the same library, vendored in for the stdlib). Note that tomllib is read-only by design — if you need to write TOML, use tomli-w or tomlkit (the latter preserves comments and formatting, which matters when you’re updating an existing file in place).
A useful pattern: read the package version once, in a CLI or web app, and surface it in --version output. With importlib.metadata you don’t even need to parse the file:
from importlib.metadata import version
print(version("weather-cli"))
That pulls from the installed package’s metadata, which was generated from pyproject.toml at build time. Single source of truth, no parsing required.
Where this leaves us
pyproject.toml is the lingua franca. Every modern package manager — pip, uv, Poetry, PDM, Hatch — reads it. Every modern build backend writes wheels from it. Every modern linter and type checker can be configured in it. The fragmented config-file zoo of 2018 has collapsed into a single, well-specified TOML file.
In the next lesson we’ll meet uv — the package manager from Astral that took over the ecosystem in 2024-2025 and is, as of 2026, the default choice for most new Python projects. It reads pyproject.toml, of course. Everything does now.