Skip to content
← All articles

pyproject.toml as the single source of truth

setup.py plus setup.cfg plus MANIFEST.in plus .flake8 plus mypy.ini plus pytest.ini plus tox.ini is why nobody knows where a setting lives and why CI and local disagree.

Open an older Python repository and count the configuration files: setup.py, setup.cfg, MANIFEST.in, .flake8, mypy.ini, pytest.ini, tox.ini, .isort.cfg. Eight files, four syntaxes, and at least two places where the same setting can be written with different precedence.

The concrete cost is not tidiness. It is that a setting can be shadowed — local reads one file, CI reads another, and the resulting “works on my machine” takes an afternoon to diagnose because nobody knows which file wins.

pyproject.toml is one file, one syntax, and one place to look.

[build-system]

[build-system]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"

This is what PEP 517/518 bought: your project declares how it is built rather than being an executable build script. Any frontend — pip, uv, build — reads these two keys, installs the requirements into an isolated environment, and calls the backend.

Pin requires with a range, not an exact version and not nothing. Exact pinning means you get no bug fixes and your build breaks when the pin becomes unavailable; unbounded means a backend major release can break your build with no change on your side.

[project], field by field

[project]
name = "pricing"
version = "1.4.2"
description = "Order pricing rules."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "A Dev", email = "dev@example.com" }]
keywords = ["pricing", "money"]
classifiers = ["Development Status :: 4 - Beta", "Programming Language :: Python :: 3.12"]
dependencies = ["httpx>=0.27", "attrs>=24.1"]

[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2"]

[project.urls]
Homepage = "https://example.com/pricing"
Source = "https://github.com/example/pricing"

[project.scripts]
pricing = "pricing.cli:main"

Three fields deserve more than a line.

requires-python is a hard gate at install time: an installer will refuse to install this distribution on an interpreter outside the range, and will pick an older release if one is compatible. It is not documentation — getting it wrong strands users on a version that cannot work, or lets them install one that crashes on syntax.

license is now an SPDX expression (PEP 639), not a table and not a free string: license = "MIT", license = "Apache-2.0 OR MIT". The accompanying license-files key lists the files to include in the distribution. The old license = { file = "LICENSE" } table form and the License :: classifiers are the legacy spelling.

dynamic lists the [project] keys the backend will fill in — most commonly version, when it is read from __init__.py or from a git tag:

[project]
name = "pricing"
dynamic = ["version"]

The rule that makes this safe is that a backend must error rather than guess for anything not listed in dynamic. There is no silent fallback: a key is either statically present, or declared dynamic, or the build fails.

💡A colleague sets version statically *and* lists it in dynamic. What happens, and why is the spec's choice the right one? click to reveal

It is an error. A key may be static or dynamic, never both, and the build frontend refuses rather than picking one.

The alternative designs are all worse. “Static wins” means a project reading its version from a git tag silently ships whatever stale literal is in the file. “Dynamic wins” means the value you can read in the repository is not the value that ships. Either way the metadata lies, and it lies in the field people trust most.

This is the general principle behind dynamic and it is worth internalising: the manifest is authoritative about where each value comes from, even when it does not contain the value itself. A tool reading pyproject.toml can always tell whether a field is knowable statically, which is what lets an installer resolve dependencies without executing your build.

What it legitimately absorbs

Tool configuration, under [tool.<name>]:

[tool.ruff]
src = ["src"]
line-length = 100

[tool.mypy]
python_version = "3.12"
strict = true

[tool.pytest.ini_options]
addopts = "-ra --strict-markers"
testpaths = ["tests"]

[tool.coverage.run]
source = ["src"]
branch = true

Ruff, mypy, pytest, coverage, hatch, uv and import-linter all read their settings from here. That is seven files gone and one place to look.

What must not go in it

Secrets. pyproject.toml is committed, shipped inside your sdist, and read by every tool that touches your project. An index token in it is a token in the artefact you upload to that index.

Environment-specific values. A database URL, a hostname, a log level, a feature flag. This file describes the distribution, and the distribution is the same object in staging and production. Configuration that differs between deployments belongs in the environment, parsed at startup into a typed object.

The dividing line is a useful test: if the value could differ between two running copies of the same version of your code, it does not belong in pyproject.toml.

💡setup.py is still in your repository and CI runs python setup.py sdist. Which part of that is deprecated? click to reveal

Only the second part. setup.py the file is not deprecated — setuptools still reads it, and it remains the way to configure a C extension build, which is genuinely imperative work that a static table cannot express.

What is deprecated is invoking it as a command-line interface: python setup.py sdist, python setup.py bdist_wheel, python setup.py install, python setup.py test. Those run your build script directly, with no isolated environment, no [build-system].requires, and no guarantee that the build dependencies are present.

The replacement is a frontend: python -m build, uv build, or pip install .. Each reads [build-system], sets up an isolated environment, and calls the backend through the PEP 517 hooks. If your project is pure Python you can usually delete setup.py entirely and move the metadata into [project]; if it builds an extension, keep the file and stop invoking it.