pyproject.toml is the central file for declaring how a Python project is built, which metadata describes it, and how compatible development tools behave. [build-system] selects a build backend, [project] records identity, Python compatibility, and runtime dependencies, and [tool.*] holds tool-specific settings. Applications usually prioritize reproducible deployment; libraries prioritize broad, accurately declared compatibility.

This guide covers project configuration rather than uploading artifacts to an index. For that separate workflow, read the guide to publishing a Python package on PyPI. Useful foundations include Python modules and packages, virtual environments, and pip dependency management.

What pyproject.toml standardizes

Older projects spread information across setup.py, setup.cfg, requirements files, and separate tool configuration. pyproject.toml provides standard integration points, but not every table is standardized by Python packaging. [project] follows a shared metadata specification; [tool.ruff] belongs exclusively to Ruff.

TOML contains keys, arrays, and tables. Comments start with #, strings require quotes, and dotted names indicate nesting. A syntactically valid file may still contain an unsupported key, so validate it by running the actual build and tools.

The official PyPA writing guide gives practical choices, while the pyproject.toml specification defines normative behavior. PEP 518 explains the motivation for isolated build requirements.

build-system selects the build process

A setuptools configuration is small:

[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

requires lists software needed to run the backend in an isolated build environment. Flask, requests, and other runtime libraries do not belong there; put them in [project].dependencies. A build frontend creates an environment, installs build requirements, and calls the backend to produce a wheel or source distribution.

Other backends require other values and settings. Follow one backend's documentation instead of combining snippets from incompatible systems. Minimum versions should correspond to features used by the project. Exact build pins can become maintenance burdens, but an unrestricted old version may be unable to interpret the configuration.

A collection of scripts that is never installed may not need a build backend. Still, packaging an application often makes imports independent of the current directory and makes tests closer to production installation. A src layout further prevents accidental imports from the repository root.

project contains identity and runtime requirements

Here is a compact library declaration:

[project]
name = "clear-text"
version = "0.3.0"
description = "Small utilities for normalizing text"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{ name = "Clear Text Team" }]
dependencies = ["regex>=2024.5"]

[project.optional-dependencies]
cli = ["rich>=13"]

[project.scripts]
clear-text = "clear_text.cli:main"

name identifies the distribution and need not exactly match the import package. requires-python blocks installation on unsupported interpreters. dependencies contains only software required at runtime. Optional dependencies expose user-facing features, installed with a command such as pip install 'clear-text[cli]'. A project script maps a shell command to an importable function.

Metadata can be static or named under dynamic. Prefer static values because frontends can inspect them without invoking backend-specific behavior. If a backend reads the version from source or source control, declare dynamic = ["version"] and configure that mechanism according to the backend. A field cannot be both statically supplied and dynamic.

Applications and libraries need different constraints

A library should normally accept a useful dependency range and test supported boundaries. Exact pins such as requests==... in runtime metadata force unnecessary resolution conflicts on consumers. Set a minimum when code relies on a feature; set a maximum only for a known incompatibility.

An application owns its deployment environment and often needs an exact, reproducible resolution. Let pyproject.toml describe direct requirement ranges and use the chosen manager's lock file for exact versions, hashes, and transitive dependencies. [project].dependencies is not itself a lock.

pytest and Ruff are development tools rather than application runtime requirements. Where supported, standardized groups can express them:

[dependency-groups]
test = ["pytest>=8", "pytest-cov>=5"]
lint = ["ruff>=0.12"]
dev = [
  { include-group = "test" },
  { include-group = "lint" },
]

Groups support local development and CI and do not become published extras. Confirm installer support. If a selected manager does not recognize groups, use its documented mechanism rather than inventing a similar-looking table.

Configure tools in their own namespaces

Each tool owns its schema. A modest pytest and Ruff configuration might be:

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

[tool.ruff]
line-length = 100
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "B"]

Do not assume similarly named options behave identically across Ruff, mypy, and coverage tools. Read each tool's documentation and keep only deliberate project choices. Copying every default adds noise and can preserve obsolete configuration.

The pytest testing guide covers fixtures and suite organization. In automation, exercise the real installation and commands as explained in GitHub Actions CI for Python.

Validate the project in a clean environment

Reading the TOML file is not enough to prove that the configuration works. A useful test follows the consumer's path: build the distributions, install the wheel, and import the package outside the repository tree. Install the build frontend in the development environment and run:

python -m pip install build
python -m build
python -m pip install --force-reinstall dist/*.whl
python -c "import clear_text; print(clear_text.__name__)"

The build should create a wheel and, normally, a source distribution. Inspect their contents before publishing. Package modules, the license, the README, and required data files should be included; caches, secrets, local artifacts, and unrelated tests generally should not. A wheel that appears to work from the repository but fails after installation often reveals incorrect package discovery or an accidental dependency on the current working directory.

For a library, also install from the source distribution. That route requires the frontend to rebuild a wheel and exposes missing build requirements. For an application, create an empty environment with the minimum declared Python version, install it through the documented procedure, and exercise at least its startup command and critical tests.

These checks cover three distinct layers. A TOML parser catches malformed syntax. The backend determines whether it understands the build and metadata tables. Installation and execution demonstrate that the artifact actually represents the project. Keeping all three in CI is more reliable than testing only an editable installation, because editable mode can make source files visible even when the wheel would omit them.

Common pitfalls

  • Putting application dependencies in [build-system].requires.
  • Exactly pinning library runtime dependencies without a concrete reason.
  • Treating a user-facing optional extra as a development group.
  • Declaring the same metadata field as static and dynamic.
  • Copying [tool.*] tables without installing or understanding the tool.
  • Assuming pyproject.toml provides an exact lock.
  • Declaring Python versions that CI does not test.
  • Combining configuration intended for different build backends.
  • Depending on imports that work only from the repository root.

Review checklist

  • The backend and build requirements are correct and sufficient.
  • Metadata is static wherever practical.
  • Runtime requirements exclude test and lint tools.
  • Extras represent optional capabilities for users.
  • Groups represent internal tasks and are installer-supported.
  • Applications have an explicit lock strategy.
  • Libraries test their declared Python and dependency ranges.
  • Every tool table follows current documentation.
  • Build, installation, and tests pass in a clean environment.

Start with the smallest configuration the project needs. Add settings only for concrete requirements, verify the package in a fresh virtual environment, and preserve the distinction among build, runtime, and development concerns. That separation keeps pyproject.toml understandable and prevents application deployment choices from leaking into library metadata.