Understanding pyproject.toml
The pyproject.toml file is the modern standard for configuring Python projects. It replaces the older setup.py, setup.cfg, and MANIFEST.in with a single, declarative configuration file. If you are building a library or application you plan to distribute, understanding pyproject.toml is essential.
Why pyproject.toml
Before PEP 518 and PEP 621, Python projects used a confusing mix of files:
setup.py— executable Python code for buildingsetup.cfg— INI-style configurationMANIFEST.in— files to include in distributionstox.ini— test automation settings
The pyproject.toml standardizes all of this. It defines how your project builds, what metadata it has, and how it should be installed. Most modern tools including pip, poetry, hatch, and PDM support it.
Basic Structure
A minimal pyproject.toml uses the [project] table to declare metadata:
[project]
name = "my-package"
version = "0.1.0"
description = "A short description of what this package does"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
authors = [
{name = "Your Name", email = "you@example.com"}
]
dependencies = [
"requests>=2.28",
"flask>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black>=23.0",
]
This single file tells pip everything it needs to install your package.
Build Systems
The [build-system] table specifies how your package should be built:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
This tells the build tools to use setuptools. The requires list specifies what dependencies the build process needs. The build-backend names the Python object that actually performs the build.
Other popular build backends include:
hatchling— used by Hatchpoetry.core.masonry.api— used by Poetryflit_core.buildapi— used by Flit
Defining Entry Points
Entry points let users run your package from the command line:
[project.scripts]
mycli = "my_package.cli:main"
[project.gui-scripts]
myapp = "my_package.gui:run"
The project.scripts section creates console executables. When installed, mycli runs the main() function from my_package.cli. The project.gui-scripts section works similarly but creates GUI applications on Windows.
Optional Dependencies
You can define optional feature groups:
[project.optional-dependencies]
testing = ["pytest>=7.0", "pytest-cov"]
docs = ["sphinx", "myst-parser"]
linting = ["ruff", "mypy"]
Users install these with:
pip install my-package[testing]
pip install my-package[docs,linting]
This lets you keep your core dependencies small while offering extras for different use cases.
Classifiers
Classifiers provide metadata for PyPI:
[project.urls]
Homepage = "https://github.com/yourname/my-package"
Repository = "https://github.com/yourname/my-package"
Issues = "https://github.com/yourname/my-package/issues"
[project.classifiers]
License = {content = "MIT"}
Programming Language = "Python :: 3"
Programming Language = "Python :: 3.9"
Programming Language = "Python :: 3.10"
Programming Language = "Python :: 3.11"
Programming Language = "Python :: 3.12"
Operating System = "OS Independent"
Classifiers are strings that describe your package. They help users find it on PyPI and understand its compatibility.
Dynamic Versioning
Instead of hardcoding the version, you can derive it dynamically:
[project]
dynamic = ["version"]
[tool.setuptools.version]
method = "setuptools_scm"
The setuptools_scm tool reads the version from your git tags. This keeps your version in sync with your source control without manual updates.
Other dynamic fields include:
version— often dynamicdependencies— for complex dependency resolution
Configuring Tools
The [tool] table configures external tools:
[tool.black]
line-length = 88
target-version = ["py39", "py310", "py311"]
[tool.ruff]
line-length = 88
select = ["E", "F", "W", "I"]
ignore = ["E501"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
Each tool has its own sub-table. This keeps all tool configuration in one place instead of scattered across multiple files like .black, tox.ini, or setup.cfg.
Type Checking Configuration
Configure type checking alongside your project:
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "numpy.*"
ignore_missing_imports = true
This tells mypy to check against Python 3.9 and flags functions without type annotations.
Complete Example
Here is a more complete pyproject.toml for a realistic project:
[project]
name = "my-awesome-package"
version = "0.2.1"
description = "A thing that does stuff"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
authors = [
{name = "Jane Developer", email = "jane@example.com"}
]
keywords = ["utility", "library", "tools"]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"requests>=2.28",
"click>=8.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black>=23.0",
"ruff>=0.1.0",
]
docs = [
"sphinx>=7.0",
"myst-parser>=2.0",
]
[project.scripts]
mytool = "my_package.cli:main"
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 88
[tool.ruff]
select = ["E", "F", "W", "I"]
ignore = ["E501"]
[tool.pytest.ini_options]
testpaths = ["tests"]
Installing Your Package
With a proper pyproject.toml, installation is straightforward:
# Install in editable mode for development
pip install -e .
# Install from PyPI
pip install my-package
# Install with extras
pip install my-package[dev,docs]
Editable installs link to your source code, so changes are immediately available without reinstalling.
See Also
- virtual-environments — Create isolated environments for your projects
- packaging-a-library — Complete guide to packaging and publishing
- argparse-module — Build command-line interfaces