Imagine you join a research project. The README says:
Install:
- pip install numpy
- pip install pandas
- pip install matplotlib
- pip install scikit-learn
- pip install pytest
- pip install black
- pip install isort
Twenty commands later, you’re finally ready.
Then a teammate says: “Actually, use Python 3.11.”
Another says: “Don’t use Black 23. Use Black 24.”
Someone else says: “Run pytest with coverage.”
And someone inevitably asks: “Wait, which dependencies are actually needed to run the project?”
Sound familiar?
For years, Python projects looked exactly like this. Configuration lived everywhere: requirements.txt, setup.py, setup.cfg, pytest.ini, mypy.ini, tox.ini, .black, .isort.cfg. Finding a project’s configuration was like looking for pieces of a treasure map.
Then came pyproject.toml.
Instead of scattering configuration across ten different files, Python finally got one place where every tool could look. Think of it as the control room of your project. Instead of asking “Where does Black store its settings?” or “Where are pytest options?”, everything simply asks the same file: pyproject.toml.
One project. One source of truth.
The idea is surprisingly simple
Imagine you’re opening a new restaurant. Before customers arrive, you need to answer some questions. What’s the restaurant called? How do people prepare food? What equipment does the kitchen need? How should staff behave? Where are the menus?
That’s exactly what pyproject.toml does. It answers questions before Python even starts running your code.
The first section: how is this project built?
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"When someone installs your package, Python first asks “How do I build this?” This section answers that question. Think of it like hiring the construction company before building a house.
Blueprint → Construction company → Finished house
Python’s blueprint is your source code. The construction company is setuptools. The finished house is an installable package.
The project itself
[project]
name = "paper-replication"
version = "0.1.0"
description = "Replication of [Paper Title]"
requires-python = ">=3.10"
dependencies = []This section is basically your project’s passport. It tells Python: “I’m called paper-replication.” “I only speak Python 3.10+.” “And before I can run, I need…” — in this case, nothing yet, since dependencies is empty until you add the actual libraries the project needs (like numpy or pandas).
Now imagine someone clones your repository. Instead of asking “Which libraries do I install?”, Python already knows.
Not every dependency is equal
Some packages are needed to run the project. Others are only needed to develop it. For example, your application probably can’t run without numpy. But black isn’t required for users — it’s only useful for developers.
That’s why we separate them:
[project.optional-dependencies]
dev = [
"black>=24.0",
"isort>=5.13",
"mypy>=1.9",
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.4",
"nbstripout>=0.7",
"nbqa>=1.8",
"jupyter>=1.0",
"ipykernel>=6.0",
"pre-commit>=3.0",
]
docs = [
"mkdocs>=1.6",
"mkdocs-material>=9.5",
"mkdocstrings[python]>=0.25",
"mkdocs-jupyter>=0.24",
]Think of it like buying a car. Every car needs an engine, wheels, and brakes. But a mechanic also needs diagnostic tools, torque wrenches, and lifts. Developers are the mechanics. Normal users just want to drive the car.
Notice there are two extra groups here beyond just dev: tools for working in notebooks (nbstripout, nbqa, jupyter, ipykernel), and a separate docs group for building documentation with MkDocs. Splitting these out means someone who just wants to write docs doesn’t need to install your entire test suite, and vice versa. Someone installs exactly what they need with commands like pip install -e ".[dev]" or pip install -e ".[docs]".
Every tool has its own little room
One thing to love about pyproject.toml is how organized it is.
[tool.black]
line-length = 88
target-version = ["py310"]
[tool.isort]
profile = "black"
line_length = 88
[tool.mypy]
python_version = "3.10"
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=70"
[tool.ruff]
line-length = 88
select = ["E", "F", "W", "I"]Black keeps its settings in [tool.black]. Pytest keeps its settings in [tool.pytest.ini_options]. Mypy lives in [tool.mypy]. Ruff lives in [tool.ruff]. Notice something interesting: nobody is fighting over configuration. Each tool has its own room inside the same house.
A few things worth pointing out in this particular setup. Mypy is set to strict = true, which means it checks types aggressively — great for catching bugs early, but worth knowing it exists so it doesn’t surprise you the first time it complains. Pytest is configured to fail the build if test coverage drops below 70%, via --cov-fail-under=70, which is a nice guardrail to keep code quality from quietly slipping. And Ruff is set up purely for linting (catching errors, unused imports, and style issues) without taking over formatting, which Black still handles.
Where does uv fit in?
This part can be confusing at first. You might assume uv is just another package manager like pip. It is — but it’s also much more.
Think of pyproject.toml as a shopping list: milk, bread, eggs, butter. The list doesn’t buy anything; it simply records what you need.
uv is the person pushing the shopping cart. When you run uv sync, uv opens pyproject.toml, reads every dependency, checks Python versions, creates a virtual environment if needed, downloads everything, and locks the exact versions. You never manually type pip install pandas, pip install numpy, pip install scipy again.
One file, many tools
Today, almost every modern Python tool understands pyproject.toml: uv, pip, setuptools, pytest, black, ruff, mypy, isort, mkdocs. Instead of each tool inventing its own configuration file, they all politely share the same one. That is why modern Python projects feel much cleaner than projects from just a few years ago.
Final thoughts
pyproject.toml isn’t just another configuration file. It’s the agreement that every tool in your project follows. Instead of asking every developer to remember dozens of installation commands, formatting rules, testing options, and Python versions, everything is written down in one place.
The result is simple: clone the repository, run one command, start building.
What’s next?
Now that our project knows what tools to use, the next question is: how do we make sure every developer actually uses them?
That’s where pre-commit hooks come in. Instead of relying on developers to remember to run Black, Ruff, or pytest manually, Git can do it automatically before every commit. We’ll build that in the next post.