
Understanding pyproject.toml: Modern Python Project Configuration
For a long time, configuring a Python project meant juggling files. Package metadata lived in setup.py (executable code, which made it hard for tools to read), or in setup.cfg. Dependencies were in requirements.txt. Then every tool brought its own config file: pytest.ini, .flake8, mypy.ini, .coveragerc, tox.ini. A small project could easily have six configuration files before it had six modules.
pyproject.toml replaced most of that with a single, declarative file. It started as a way to declare build requirements (PEP 518), grew a standard table for project metadata (PEP 621), and now also holds development dependency groups (PEP 735) and configuration for nearly every popular tool. Whether you use pip, uv, Poetry, Hatch, or PDM, this file is the center of a modern Python project.
This post walks through it table by table: what each section is for, which fields matter, how dependencies and version specifiers work, how build backends fit in, and the mistakes that trip people up. At the end there's a complete example that you can build and install.
The Shape of the File
pyproject.toml is written in TOML, a simple config format with tables ([section]), key-value pairs, strings, arrays, and inline tables. A typical file has four kinds of top-level tables:
| Table | Purpose | Defined by |
|---|---|---|
[build-system] | Which tool builds your package | PEP 518 / PEP 517 |
[project] | Package name, version, dependencies, and other metadata | PEP 621 |
[dependency-groups] | Development-only dependency lists (test, lint, docs) | PEP 735 |
[tool.<name>] | Configuration for individual tools (Ruff, pytest, mypy, ...) | Each tool |
Only the standard tables have fixed meanings. Everything under [tool.*] belongs to the tool named in it, and other tools ignore it.
[build-system]: How Your Package Gets Built
When you or a tool like pip builds your project into a wheel, it needs to know which build backend to use. That's what this table declares:
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
requireslists the packages needed to build the project. Frontends like pip and uv install them into an isolated, temporary environment.build-backendis the Python object that does the building, following the PEP 517 interface.
Common backends and their declarations:
| Backend | requires | build-backend |
|---|---|---|
| Hatchling | ["hatchling"] | "hatchling.build" |
| setuptools | ["setuptools>=77"] | "setuptools.build_meta" |
| uv | ["uv_build>=0.12,<0.13"] | "uv_build" |
| Poetry | ["poetry-core>=2.0"] | "poetry.core.masonry.api" |
| Flit | ["flit_core>=3.12"] | "flit_core.buildapi" |
| PDM | ["pdm-backend"] | "pdm.backend" |
All of them read the same standard [project] table, so switching backends is usually just a matter of changing these two lines (plus any backend-specific [tool.*] settings). Pure-Python projects work fine with any of them. setuptools is still the choice for projects with C extensions; scikit-build-core and maturin handle CMake and Rust extensions.
Do You Need a [build-system] at All?
Only if your project is meant to be installed as a package: a library you publish, or an application with command-line entry points. A web app or a collection of scripts that you only run from source doesn't need one. uv, for example, treats a project without [build-system] as non-packaged and just installs its dependencies.
If you leave it out but something does try to build the project, pip falls back to legacy setuptools behavior, which is rarely what you want. Be explicit when packaging matters.
[project]: Your Package's Metadata
This is the standard table for everything about your package. Build backends turn it into the metadata that appears on PyPI and in pip show.
Required and Core Fields
[project]
name = "invoicer"
version = "0.3.0"
description = "Generate PDF invoices from YAML files."
readme = "README.md"
requires-python = ">=3.12"
nameis the distribution name used on PyPI and inpip install. It's required. It doesn't have to match your import name, but it's simpler when it does (with dashes in the distribution name becoming underscores in the import name).versionis required unless declared dynamic (see below). Use PEP 440 versions like1.2.0,2.0.0rc1, or0.3.0.dev2.descriptionis the one-line summary shown in search results.readmepoints at a file whose contents become the long description on PyPI. The content type is inferred from the extension (.md,.rst).requires-pythonstates which Python versions your package supports. Installers refuse to install it on other versions, and tools like uv use it to resolve dependencies. Set it deliberately, typically to the oldest version you test against.
People, License, and Discovery
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Your Name", email = "you@example.com" }]
maintainers = [{ name = "Another Person", email = "other@example.com" }]
keywords = ["invoice", "pdf", "billing"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: OS Independent",
]
licenseis now an SPDX license expression string, such as"MIT","Apache-2.0", or"MIT OR Apache-2.0", as defined by PEP 639. Older projects use the table formlicense = { text = "MIT" }orlicense = { file = "LICENSE" }, which is deprecated. Don't also addLicense ::classifiers when you use an expression; modern backends reject that combination.license-fileslists glob patterns for license files to include in the distribution.authorsandmaintainersare arrays of inline tables withname,email, or both.classifiersare Trove classifiers that categorize your project on PyPI. They're informational;requires-pythonis what actually restricts installation.
URLs
[project.urls]
Homepage = "https://example.com/invoicer"
Repository = "https://github.com/example/invoicer"
Issues = "https://github.com/example/invoicer/issues"
Documentation = "https://invoicer.readthedocs.io"
Changelog = "https://github.com/example/invoicer/blob/main/CHANGELOG.md"
These appear in the sidebar on PyPI. Labels are free-form, but PyPI recognizes common ones like Homepage, Source/Repository, Issues, Documentation, and Changelog and shows matching icons.
Declaring Dependencies
dependencies
Runtime dependencies go in an array of PEP 508 requirement strings:
dependencies = [
"httpx>=0.28",
"pyyaml>=6.0",
"colorama>=0.4; sys_platform == 'win32'",
]
Each string has a package name, an optional version specifier, and an optional environment marker after a semicolon. The last line installs colorama only on Windows.
The version operators you'll use:
| Specifier | Meaning |
|---|---|
>=2.0 | 2.0 or newer |
>=2.0,<3 | At least 2.0 but below 3 |
~=2.4 | Compatible release: >=2.4,<3.0 |
~=2.4.1 | Compatible release: >=2.4.1,<2.5 |
==2.4.* | Any 2.4.x |
==2.4.1 | Exactly 2.4.1 |
!=2.4.3 | Anything except 2.4.3 |
Useful markers include python_version < '3.12', sys_platform == 'linux', platform_machine == 'arm64', and implementation_name == 'cpython'.
How tight should constraints be? For libraries, use lower bounds (>=) and avoid upper bounds unless you know a version breaks you; tight caps cause unsolvable conflicts for your users. For applications, lower bounds are fine too, because the lock file is what pins exact versions. Never pin exact versions (==) in dependencies for a library.
Optional Dependencies (Extras)
Features that need extra packages go in [project.optional-dependencies]. Users opt in with brackets:
[project.optional-dependencies]
pdf = ["reportlab>=4.0"]
s3 = ["boto3>=1.35"]
all = ["invoicer[pdf,s3]"]
pip install "invoicer[pdf]"
Extras are part of your published package's metadata. They're for users of your package, not for your development tools.
[dependency-groups]: Development Dependencies
Test runners, linters, and doc generators are needed by people working on the project, not by people installing it. Historically these went in extras like [dev] or separate requirements files. PEP 735 added a standard table for them:
[dependency-groups]
test = ["pytest>=8.3", "pytest-cov>=6.0"]
lint = ["ruff>=0.15"]
dev = [{ include-group = "test" }, { include-group = "lint" }]
Groups aren't included in built packages, so they never leak to users. A group can include another with { include-group = "..." }. Support is now broad:
uv sync # installs the "dev" group by default
uv sync --group test # add a specific group
pip install --group test # pip 25.1 and later
poetry install --with test # Poetry 2.x reads this table too
Dynamic Fields
Sometimes a value shouldn't be hard-coded in pyproject.toml. The most common case is the version, which you may want to keep in your code or derive from Git tags. List such fields in dynamic, and the build backend fills them in:
[project]
name = "invoicer"
dynamic = ["version"]
[tool.hatch.version]
path = "src/invoicer/__init__.py"
With Hatchling, this reads __version__ = "0.3.0" from the given file. Other backends have equivalents: setuptools uses [tool.setuptools.dynamic], and plugins like hatch-vcs or setuptools-scm derive versions from Git tags.
A field must be either set statically or listed in dynamic, never both and never neither (for required fields). You can't mix a static value with backend magic.
If you only need the version at runtime, a simpler pattern is to keep it static in pyproject.toml and read the installed metadata:
# src/invoicer/__init__.py
from importlib.metadata import version
__version__ = version("invoicer")
This requires the package to be installed (including in editable mode), which is the case when you use uv, Poetry, or pip install -e ..
Entry Points: Command-Line Scripts
To give users a command they can run after installing your package, map a command name to a function:
[project.scripts]
invoicer = "invoicer.cli:main"
The value is module.path:function. On install, the installer generates a small executable named invoicer that imports invoicer.cli and calls main():
# src/invoicer/cli.py
import argparse
from invoicer import __version__
def main() -> None:
parser = argparse.ArgumentParser(prog="invoicer")
parser.add_argument(
"--version", action="version", version=f"%(prog)s {__version__}"
)
parser.parse_args()
print("Generating invoices...")
uv run invoicer --version
uv run invoicer
invoicer 0.3.0
Generating invoices...
There's also [project.gui-scripts] for GUI apps (no console window on Windows) and [project.entry-points."group.name"] for plugin systems, where other packages discover your code through importlib.metadata.entry_points().
[tool.*]: Configuring Your Tools
Most of the Python tooling ecosystem reads its settings from pyproject.toml now. A few common examples:
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
[tool.mypy]
strict = true
python_version = "3.12"
[tool.coverage.run]
source = ["invoicer"]
branch = true
Each tool documents its own keys. A few notes:
- pytest's table is
[tool.pytest.ini_options], a name kept for compatibility withpytest.ini. - Some tools still have their own files and look there first (Ruff checks
ruff.tomland.ruff.tomlbeforepyproject.toml; mypy checksmypy.ini). If a setting seems ignored, look for a competing config file. - Flake8 doesn't read
pyproject.tomlnatively. Ruff covers its rules and does.
Build backends use this space too, for things like package discovery: [tool.hatch.build.targets.wheel], [tool.setuptools.packages.find], or [tool.uv] for uv's own settings.
Project Layout: Where the Code Goes
Backends need to find your importable package. The two common layouts:
src layout flat layout
invoicer/ invoicer/
├── pyproject.toml ├── pyproject.toml
├── README.md ├── README.md
├── src/ ├── invoicer/
│ └── invoicer/ │ ├── __init__.py
│ ├── __init__.py │ └── cli.py
│ └── cli.py └── tests/
└── tests/
The src layout is the common recommendation for packages. Because your code isn't on sys.path just by being in the current directory, tests run against the installed package, which catches packaging mistakes like missing files. Hatchling, setuptools, and uv_build all discover the src layout automatically when the directory name matches the project name. For a refresher on how packages and imports work, see What Are Python Modules and Packages.
A Complete Example
Putting it together, here's a full pyproject.toml for the example project:
# pyproject.toml
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project]
name = "invoicer"
version = "0.3.0"
description = "Generate PDF invoices from YAML files."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Your Name", email = "you@example.com" }]
keywords = ["invoice", "pdf", "billing"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: OS Independent",
]
dependencies = [
"httpx>=0.28",
"pyyaml>=6.0",
"colorama>=0.4; sys_platform == 'win32'",
]
[project.optional-dependencies]
pdf = ["reportlab>=4.0"]
[project.scripts]
invoicer = "invoicer.cli:main"
[project.urls]
Homepage = "https://example.com/invoicer"
Repository = "https://github.com/example/invoicer"
Issues = "https://github.com/example/invoicer/issues"
[dependency-groups]
test = ["pytest>=8.3", "pytest-cov>=6.0"]
lint = ["ruff>=0.15"]
dev = [{ include-group = "test" }, { include-group = "lint" }]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
[tool.mypy]
strict = true
python_version = "3.12"
[tool.coverage.run]
source = ["invoicer"]
branch = true
With a README.md, a LICENSE file, and the src/invoicer/ package from earlier, this builds cleanly:
uv build
Successfully built dist/invoicer-0.3.0.tar.gz
Successfully built dist/invoicer-0.3.0-py3-none-any.whl
The metadata inside the wheel shows how each field was translated:
Metadata-Version: 2.5
Name: invoicer
Version: 0.3.0
Summary: Generate PDF invoices from YAML files.
Project-URL: Homepage, https://example.com/invoicer
Project-URL: Repository, https://github.com/example/invoicer
Project-URL: Issues, https://github.com/example/invoicer/issues
Author-email: Your Name <you@example.com>
License-Expression: MIT
License-File: LICENSE
Keywords: billing,invoice,pdf
...
Requires-Python: >=3.12
Requires-Dist: colorama>=0.4; sys_platform == 'win32'
Requires-Dist: httpx>=0.28
Requires-Dist: pyyaml>=6.0
Provides-Extra: pdf
Requires-Dist: reportlab>=4.0; extra == 'pdf'
Note what's not there: nothing from [dependency-groups] or [tool.*]. Those stay with the source repository. Building is a good way to check that your configuration says what you think it does. If you swap the build system for setuptools>=77 and setuptools.build_meta, the same file builds with the same metadata.
Reading pyproject.toml from Python
Since Python 3.11, the standard library includes tomllib for reading TOML. That's handy for scripts that need project information:
# read_config.py
import tomllib
from pathlib import Path
with Path("pyproject.toml").open("rb") as f:
config = tomllib.load(f)
project = config["project"]
print(project["name"], project["version"])
print("requires:", ", ".join(project["dependencies"]))
print("ruff line length:", config["tool"]["ruff"]["line-length"])
invoicer 0.3.0
requires: httpx>=0.28, pyyaml>=6.0, colorama>=0.4; sys_platform == 'win32'
ruff line length: 100
tomllib.load() needs the file opened in binary mode. The module is read-only; to write TOML, use a third-party library such as tomlkit, which also preserves comments and formatting.
Common Mistakes
- Putting dev tools in
dependencies. pytest and Ruff independenciesget installed for every user of your package. Use[dependency-groups]. - Using
[tool.poetry.dependencies]in new projects. That was Poetry 1.x's format. Poetry 2.x uses the standard[project]table, and so does everything else. - Pinning exact versions in a library.
requests==2.32.3in a library's dependencies forces that version on every user. Pin in lock files, not independencies. - Forgetting
requires-python. Without it, installers will happily put your 3.12-only code on Python 3.9, and resolvers can't pick compatible dependency versions. - Mixing static and dynamic. A field listed in
dynamicmust not also be set in[project]. - Keeping a stale
setup.pyorsetup.cfgaround with conflicting metadata. If everything is inpyproject.toml, delete them (a minimalsetup.pyis only needed for unusual setuptools builds). - Invalid TOML. Strings need quotes, and inline tables (
{ ... }) must stay on one line. Building the project or loading it withtomllibwill catch syntax errors quickly.
Where Lock Files Fit
pyproject.toml declares what your project accepts: version ranges. It doesn't say which exact versions to install. That's the job of a lock file (uv.lock, poetry.lock, or a compiled requirements.txt), generated by your dependency manager from this file. For how the tools differ, see Poetry vs uv vs pip-tools, and for a hands-on workflow, Managing Python Projects with uv.
Conclusion
pyproject.toml is the single source of truth for a modern Python project. [build-system] says how to build it, [project] describes the package and its runtime dependencies in a standard format every tool understands, [dependency-groups] keeps development tools out of your published metadata, and [tool.*] collects configuration that used to be spread across a handful of files.
Write dependencies as ranges, set requires-python deliberately, use an SPDX string for license, and let your dependency manager handle exact pins in a lock file. When in doubt, build the project and look at the generated metadata. It shows exactly what your configuration means.


