pyproject.toml, standardized across PEP 517, 518, and 621, is now the single source of truth for a Python project's build system, dependencies, and metadata ā and doubles as the shared configuration file for tools like ruff, mypy, and pytest. This lesson teaches you to read and write one fluently.
1[build-system]: How to Build, Before What to Build
Before pip can install your project from source, it needs to know *how* to turn your source tree into an installable distribution ā that's a separate question from what your project's own dependencies are, and it's answered by the [build-system] table, standardized by PEP 517 and PEP 518. requires = ["hatchling"] declares which build backend package pip should install (in an isolated environment) to perform the build; build-backend = "hatchling.build" tells it which importable object in that package implements the actual build interface.
This might look like unnecessary indirection, but it's what makes the Python packaging ecosystem backend-agnostic: hatchling, setuptools, poetry-core, flit-core, and others all implement the same PEP 517 interface, so any PEP 517-compliant tool (pip, build, uv) can build any project correctly without needing backend-specific logic hardcoded into it. Before this standard existed, every project had an executable setup.py that pip had to actually run to figure out how to build it ā fragile, and a real security concern, since running arbitrary code from an untrusted package just to inspect it is inherently risky.
For most new projects, you pick one build backend (Hatchling and setuptools are both common, mature choices) once at project creation, and rarely think about [build-system] again ā it's boilerplate you copy once, not something you hand-tune per project.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"Declares HOW to build ā resolved before your project's own deps
2[project]: Metadata That Replaced setup.py
[project], standardized by PEP 621, holds exactly the metadata that used to require an executable setup.py calling setup(name=..., version=..., install_requires=[...]) ā but as static, declarative TOML data instead of executable Python code. name, version, requires-python (the minimum/range of supported Python versions), and dependencies (the runtime dependency list, equivalent to the old install_requires) are the fields you'll touch most often.
Declaring dependencies statically here, rather than in a separate requirements.txt, means your project's dependency *requirements* (what it needs, expressed as version ranges like httpx>=0.27) live in the same file as everything else about the project. It's important to distinguish this from a *lock file* (like uv.lock or poetry.lock, covered in the Dependency Management lesson) ā [project.dependencies] says what's acceptable, a lock file pins exactly what's actually installed, down to the specific version and hash.
requires-python = ">=3.11" is more than documentation ā build tools and installers actively enforce it, refusing to install your package on an interpreter version outside that range, catching a genuine incompatibility (using a 3.11-only syntax feature, for instance) at install time rather than as a confusing runtime SyntaxError for a user on an older interpreter.
[project]
name = "my-package"
version = "1.2.0"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
]name, version, dependencies, requires-python ā all static TOML, no executable setup.py
3Optional Dependencies and Shared Tool Configuration
[project.optional-dependencies] defines named groups of dependencies that are *not* installed by default ā dev = ["pytest>=8.0", "ruff>=0.5", "mypy>=1.10"] means a plain pip install my-package skips these entirely, while pip install -e ".[dev]" installs the base package plus everything in the dev group. This is the standard mechanism for separating what end users of a published library actually need at runtime from what contributors need to develop and test it ā keeping a production install lean while still declaring dev tooling requirements in one canonical place.
Beyond project metadata, pyproject.toml has become the de facto shared configuration file for the broader tooling ecosystem too, via [tool.X] tables ā [tool.ruff], [tool.mypy], [tool.pytest.ini_options], [tool.black] (though Black has increasingly deferred to Ruff's formatter in many projects) all read their settings from here, each tool ignoring every other tool's table. This consolidation is a genuine ergonomic win over the previous era of one dotfile per tool (.flake8, setup.cfg, pytest.ini, mypy.ini) scattered across the project root.
The practical result: a new contributor cloning a well-configured modern Python project can understand nearly everything about how it's built, what it depends on, and how it's linted/typed/tested by reading a single file top to bottom ā a meaningful improvement in discoverability over the fragmented, multi-file configuration Python projects historically required.
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5", "mypy>=1.10"]
# Install with:
# pip install -e ".[dev]"One file instead of four+ dotfiles
4Step-by-Step Breakdown
One TOML file now does what used to take setup.py, setup.cfg, requirements.txt, MANIFEST.in, and a handful of dotfiles. Let's learn its anatomy.
[build-system] tells pip HOW to build your package before it even looks at your code ā which tool to invoke.
Checkpoint: What does the [build-system] table specify?
- āWhich tool (build backend) should be used to build the package, before pip even looks at your source
- āThe package's runtime dependencies needed to actually run the code
[project] holds the metadata that used to live in setup.py's setup() call ā name, version, dependencies, Python version support.
Optional dependency groups ā like [project.optional-dependencies] dev ā let you install extras only when needed.
Checkpoint: What does pip install -e ".[dev]" install, compared to pip install -e .?
- āThe base package plus the optional "dev" dependency group
- āExactly the same thing ā [dev] has no effect
Tool configuration lives in [tool.X] sections ā ruff, mypy, and pytest can all be configured in this one file instead of separate dotfiles.
With the project's metadata and build system declared, dependency management is the next layer ā how those dependencies get resolved, locked, and installed.
Parse a Real pyproject.toml. Finish get_dependencies(): tomllib parses TOML into a plain nested dict.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Pin requires-python accurately and let build tools enforce it
A precise requires-python (e.g. ">=3.11") turns a version incompatibility into a clear install-time error instead of a confusing runtime SyntaxError or AttributeError for users on an unsupported interpreter.
Separate runtime dependencies from dev/test tooling via optional-dependencies groups
Keeping [project.dependencies] lean (only what end users need at runtime) while dev tools live under an extras group like [dev] keeps production installs fast and minimal.
Frequent Bugs
Adding a test-only or lint-only dependency (like pytest or ruff) directly to [project.dependencies] instead of an optional-dependencies group, forcing every end user to install dev tooling they'll never use.
Move dev/test/lint tooling into [project.optional-dependencies] under a group like dev, and install it locally with pip install -e ".[dev]" ā keep [project.dependencies] limited to genuine runtime requirements.
Real-World Examples
A Complete Minimal pyproject.toml for a New Library
A team is bootstrapping a new internal library and needs a pyproject.toml covering the build system, metadata, runtime dependencies, dev extras, and shared tool configuration in one file.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "acme-toolkit"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27"]
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5", "mypy>=1.10"]
[tool.ruff]
line-length = 100
[tool.pytest.ini_options]
testpaths = ["tests"]