Intermediate
If you have ever cloned a Python project and found a setup.py, a setup.cfg, a requirements.txt, a requirements-dev.txt, and a tox.ini all sitting in the root folder, you know the problem with the old way of doing things. Each file solved one part of the packaging puzzle, but together they created a mess that was hard to read, easy to get wrong, and painful to maintain. The Python community standardized a better solution: pyproject.toml. Since PEP 517 and PEP 518 (and later PEP 621), pyproject.toml is the single file that defines your project — its name, version, dependencies, dev tools, scripts, and build system — all in one place.
You do not need any special tool to use pyproject.toml. It is a standard TOML file that pip, uv, Poetry, Hatch, and every modern Python build tool understands. Whether you are building a library to publish to PyPI or just organizing a personal project, pyproject.toml gives you a clean, declarative way to describe it. The learning curve is minimal — TOML is simpler than both YAML and INI — and the payoff is a project structure that any Python developer can understand in seconds.
In this article we will cover the structure and syntax of pyproject.toml, how to declare project metadata and dependencies, how to define optional dev dependencies, how to configure tools like pytest, Black, and Ruff from the same file, how to define console scripts, and how to use the file with both pip and uv. By the end you will be able to replace your scattered configuration files with a single clean pyproject.toml.
pyproject.toml Quick Example
Here is a complete minimal pyproject.toml for a Python project. You can drop this into any project folder and it will work immediately with pip and uv:
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-data-tool"
version = "0.1.0"
description = "A tool for processing data files"
requires-python = ">=3.11"
dependencies = [
"polars>=0.20",
"httpx>=0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
]
[project.scripts]
data-tool = "my_data_tool.cli:main"
This single file tells every Python tool everything it needs to know: what your project is named, what Python version it requires, what packages to install, and what command-line entry point to create. Install it in a fresh virtualenv with pip install -e .[dev] and you will have the project plus all dev dependencies ready to go.
We will cover each section in detail below, including how to add tool-specific configuration for pytest, Ruff, and Black.
What Is pyproject.toml?
pyproject.toml is a TOML configuration file that lives in the root of your Python project. TOML (Tom’s Obvious, Minimal Language) is a simple key-value format with support for nested sections using square brackets. It is easier to read than YAML and more structured than INI files. The file has three main purposes: declaring the build system (how to turn your source code into a distributable package), declaring project metadata (name, version, dependencies), and configuring dev tools (linters, formatters, test runners).
| Old approach | What it did | Replaced by in pyproject.toml |
|---|---|---|
| setup.py | Package metadata + build script | [project] + [build-system] |
| setup.cfg | Declarative package metadata | [project] |
| requirements.txt | Runtime dependencies | [project].dependencies |
| requirements-dev.txt | Dev dependencies | [project.optional-dependencies].dev |
| tox.ini / .flake8 / mypy.ini | Tool configuration | [tool.X] sections |
The [build-system] table is the only required section. Everything else is optional but strongly recommended for any project you intend to share or maintain over time.
The [build-system] Section
The [build-system] table tells pip and other tools which backend to use to build your package. The three most common choices are Hatchling (modern, fast), setuptools (traditional, most compatible), and Flit (minimal, great for simple libraries):
# pyproject.toml -- build-system options
# Option 1: Hatchling (recommended for new projects)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# Option 2: setuptools (most compatible with legacy projects)
# [build-system]
# requires = ["setuptools>=68", "wheel"]
# build-backend = "setuptools.backends.legacy:build"
# Option 3: Flit (minimal, great for pure-Python libraries)
# [build-system]
# requires = ["flit_core>=3.4"]
# build-backend = "flit_core.buildapi"
Output when installing:
$ pip install -e .
Successfully built my-data-tool
Installing collected packages: my-data-tool
Successfully installed my-data-tool-0.1.0
If you are not publishing to PyPI and just want to organize your project locally, you can omit the [build-system] section entirely. But including it means any tool — pip, uv, build — knows how to package your code without guessing.
Project Metadata and Dependencies
The [project] table is where you declare your package. Here is a complete example with all the commonly used fields:
# pyproject.toml -- full project metadata
[project]
name = "weather-fetcher"
version = "1.2.0"
description = "Fetch and cache weather data from open APIs"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
authors = [
{name = "Your Name", email = "you@example.com"},
]
keywords = ["weather", "api", "data"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
]
# Runtime dependencies -- installed when someone does: pip install weather-fetcher
dependencies = [
"httpx>=0.27",
"tenacity>=8.0", # retry logic
"pydantic>=2.0", # data validation
]
[project.optional-dependencies]
# Installed with: pip install weather-fetcher[dev]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.4",
"mypy>=1.8",
]
# Installed with: pip install weather-fetcher[cache]
cache = [
"redis>=5.0",
]
[project.scripts]
# Creates a 'weather' command that calls weather_fetcher/cli.py -> main()
weather = "weather_fetcher.cli:main"
Dependency version specifiers use the same syntax as pip: >= for minimum version, ~= for compatible release (e.g., ~=1.2 allows 1.2.x but not 1.3), and == for exact version. The optional dependencies (also called “extras”) let users install additional packages for specific use cases without bloating the base install.
Configuring Tools in [tool.*] Sections
One of the most useful features of pyproject.toml is that most Python dev tools can read their configuration from it. This means you can replace .flake8, .ruff.toml, mypy.ini, and pytest.ini with sections in the same file:
# pyproject.toml -- tool configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "--tb=short -q"
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"] # pycodestyle, pyflakes, isort, pyupgrade
ignore = ["E501"]
[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true
[tool.coverage.run]
source = ["weather_fetcher"]
omit = ["tests/*"]
[tool.coverage.report]
show_missing = true
Running the configured tools:
# Run tests (uses [tool.pytest.ini_options] automatically)
$ pytest
# Lint with Ruff (uses [tool.ruff] automatically)
$ ruff check .
# Type-check with mypy (uses [tool.mypy] automatically)
$ mypy weather_fetcher/
Each tool reads its own [tool.TOOLNAME] section when invoked from a directory containing a pyproject.toml. No flags, no extra config files. This makes CI pipelines simpler — one checkout, one config file, all tools use consistent settings.
Using pyproject.toml with uv
uv is the fastest modern Python package manager (written in Rust, 10-100x faster than pip). It reads pyproject.toml natively and adds a uv.lock lockfile for reproducible installs. Here is the complete workflow:
# terminal -- using pyproject.toml with uv
# Create a new project (creates pyproject.toml automatically)
uv init my-project
cd my-project
# Add runtime dependencies (updates pyproject.toml + uv.lock)
uv add httpx polars
# Add dev dependencies to the [dev] optional group
uv add --dev pytest ruff mypy
# Install everything (reads pyproject.toml + uv.lock)
uv sync
# Run a script in the project environment
uv run python my_project/main.py
# Run tests
uv run pytest
Output of uv add httpx:
Resolved 3 packages in 0.42s
Built my-project @ file:///path/to/my-project
Prepared 1 package in 0.89s
Installed 1 package in 0.11s
+ httpx==0.27.2
When you run uv add, it updates both pyproject.toml (with the dependency specifier) and uv.lock (with the exact resolved version). Committing uv.lock to version control means every developer and every CI run installs exactly the same package versions — no “works on my machine” dependency drift.
Real-Life Example: Complete Project Setup
Here is a complete pyproject.toml for a realistic Python CLI tool, along with the minimal project structure it expects:
# pyproject.toml -- complete real project
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "csv-cleaner"
version = "0.3.0"
description = "Clean and validate CSV files from the command line"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"polars>=0.20",
"rich>=13.0", # pretty terminal output
"typer>=0.12", # CLI framework
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.4",
"mypy>=1.8",
]
[project.scripts]
csv-cleaner = "csv_cleaner.cli:app"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=csv_cleaner --cov-report=term-missing"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.mypy]
python_version = "3.11"
strict = true
Expected project structure:
csv-cleaner/
pyproject.toml # everything defined here
README.md
csv_cleaner/
__init__.py
cli.py # defines: app = typer.Typer()
cleaner.py
tests/
test_cleaner.py
Install this project for development with pip install -e .[dev] or uv sync --extra dev. The csv-cleaner command will be available in your terminal. Teammates clone the repo, run one install command, and are ready to develop — no hunting through five config files to understand the project’s requirements.
Frequently Asked Questions
Do I still need requirements.txt if I use pyproject.toml?
For most projects, no. The [project].dependencies list replaces requirements.txt, and [project.optional-dependencies].dev replaces requirements-dev.txt. If you need a pinned lockfile for reproducible CI installs, use uv.lock (generated by uv) or pip freeze > requirements-lock.txt. Some teams still generate a requirements.txt from the lockfile for compatibility with older Docker images, but it is no longer the primary source of truth.
Should I use Poetry, Hatch, or uv with pyproject.toml?
All three read pyproject.toml natively. uv is the fastest and simplest — install it, run uv init, and you are done. Poetry has more opinionated defaults and its own lockfile format (poetry.lock). Hatch is great for projects that need multiple Python version testing environments. For new projects in 2026, uv is the recommended choice because of its speed and compatibility with the standard pyproject.toml format that pip also understands.
How do I manage the version number without duplicating it?
Use dynamic = ["version"] in the [project] table and set the version in your source code. With Hatchling, add [tool.hatch.version] path = "my_package/__init__.py" and define __version__ = "1.0.0" in that file. Hatchling reads the version from there automatically. This means you only update the version in one place and it flows through to both the package metadata and your code.
How do I include non-Python files (data files, templates)?
With Hatchling, non-Python files inside your package directory are included automatically. For files outside the package (like config templates in a data/ folder), add them under [tool.hatch.build.targets.wheel.include]. With setuptools, use [tool.setuptools.package-data]. Either way, the key is that these rules all live in pyproject.toml — no need for a separate MANIFEST.in file for most use cases.
How do I migrate an existing project from setup.py to pyproject.toml?
Run pip install ini2toml and then ini2toml setup.cfg — this converts most setup.cfg files to pyproject.toml format automatically. For setup.py, you will need to manually copy the metadata into [project]. After migrating, test with pip install -e . in a fresh virtualenv, run your test suite, and build a distribution with python -m build to verify the package builds correctly before deleting the old files.
Conclusion
Adopting pyproject.toml is one of the highest-leverage improvements you can make to any Python project’s developer experience. We covered the four key sections — [build-system], [project], [project.optional-dependencies], and [tool.*] — and showed how they replace five or more legacy config files. The complete example demonstrated a realistic CLI project that any developer can clone and run with a single install command.
The best next step is to open one of your existing projects and start a pyproject.toml with just the [project] section, copying your dependencies from requirements.txt. Then progressively add tool configuration sections as you need them. The official Python Packaging Guide has the complete reference for every supported field.