Type something to search...
Understanding pyproject.toml: Modern Python Project Configuration

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:

TablePurposeDefined by
[build-system]Which tool builds your packagePEP 518 / PEP 517
[project]Package name, version, dependencies, and other metadataPEP 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"
  • requires lists the packages needed to build the project. Frontends like pip and uv install them into an isolated, temporary environment.
  • build-backend is the Python object that does the building, following the PEP 517 interface.

Common backends and their declarations:

Backendrequiresbuild-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"
  • name is the distribution name used on PyPI and in pip 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).
  • version is required unless declared dynamic (see below). Use PEP 440 versions like 1.2.0, 2.0.0rc1, or 0.3.0.dev2.
  • description is the one-line summary shown in search results.
  • readme points at a file whose contents become the long description on PyPI. The content type is inferred from the extension (.md, .rst).
  • requires-python states 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",
]
  • license is 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 form license = { text = "MIT" } or license = { file = "LICENSE" }, which is deprecated. Don't also add License :: classifiers when you use an expression; modern backends reject that combination.
  • license-files lists glob patterns for license files to include in the distribution.
  • authors and maintainers are arrays of inline tables with name, email, or both.
  • classifiers are Trove classifiers that categorize your project on PyPI. They're informational; requires-python is 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:

SpecifierMeaning
>=2.02.0 or newer
>=2.0,<3At least 2.0 but below 3
~=2.4Compatible release: >=2.4,<3.0
~=2.4.1Compatible release: >=2.4.1,<2.5
==2.4.*Any 2.4.x
==2.4.1Exactly 2.4.1
!=2.4.3Anything 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 with pytest.ini.
  • Some tools still have their own files and look there first (Ruff checks ruff.toml and .ruff.toml before pyproject.toml; mypy checks mypy.ini). If a setting seems ignored, look for a competing config file.
  • Flake8 doesn't read pyproject.toml natively. 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 in dependencies get 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.3 in a library's dependencies forces that version on every user. Pin in lock files, not in dependencies.
  • 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 dynamic must not also be set in [project].
  • Keeping a stale setup.py or setup.cfg around with conflicting metadata. If everything is in pyproject.toml, delete them (a minimal setup.py is 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 with tomllib will 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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading