What's the role of `pyproject.toml`, and how has Python packaging evolved?
Quick Answer
pyproject.toml (PEP 518/621) is the modern, standardized single file for declaring a project's build system, dependencies, and metadata, replacing the older pattern of a setup.py (executable Python code, historically a security/reproducibility concern) plus setup.cfg/requirements.txt scattered across multiple files. Nearly all modern tools (pip, poetry, hatch, build) now read project configuration from pyproject.toml as the single source of truth.
Detailed Answer
The old way: setup.py as executable code
# setup.py (legacy)
from setuptools import setup
setup(
name="myproject",
version="1.0.0",
install_requires=["requests>=2.0"],
)
Because setup.py is a Python script, building or installing a package
required actually executing arbitrary code just to read its metadata.
This is a real reproducibility and security concern: a malicious or broken
setup.py could do anything at install time, and different environments
could produce different results running the "same" setup.py.
The modern way: declarative pyproject.toml
# pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "myproject"
version = "1.0.0"
dependencies = ["requests>=2.0"]
requires-python = ">=3.9"
[project.optional-dependencies]
dev = ["pytest", "mypy", "ruff"]
[project.scripts]
mycli = "myproject.cli:main"
This is plain, static TOML data — no code execution needed to read
project metadata, dependencies, or entry points. [build-system] (PEP
518) declares what's needed to build the project before even importing
setuptools. [project] (PEP 621) standardizes metadata that used to be
scattered across setup.py/setup.cfg/Pipfile in tool-specific formats.
Why this matters: one format, many tools
[tool.poetry.dependencies]
python = "^3.9"
requests = "^2.0"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
Beyond the standardized [project] table, tools can add their own
[tool.*] sections in the same file. poetry, pytest, ruff,
black, mypy all support configuration directly in pyproject.toml,
consolidating what used to be setup.cfg, pytest.ini, .flake8, and
various other tool-specific config files into one place.
The evolution, in short
distutils/setup.py(original, Python 2 era) — code-based, minimal metadata standardization.setuptools+setup.cfg— moved some metadata to a declarative INI-style file, butsetup.pywas often still required as a shim.pyproject.toml(current standard, PEP 518/621) — fully declarative project metadata and build-system requirements.setup.pyis no longer required at all for most modern projects (thoughsetuptoolscan still use one for complex custom build logic).
Building and publishing today
python -m build # builds a wheel (.whl) and sdist (.tar.gz) from pyproject.toml
python -m twine upload dist/* # publishes to PyPI
The build package is the modern, backend-agnostic way to build a
distributable package purely from pyproject.toml, regardless of which
build backend (setuptools, hatchling, poetry-core) the project uses.
pyproject.toml replaced the historical
mix of executable setup.py and scattered config files with one
declarative, standardized file for build requirements, project metadata,
and dependencies. It's read by pip, build, and virtually every modern
Python tool, eliminating the need to execute arbitrary code just to
discover a package's metadata.