Why I Ditched setup.py for pyproject.toml: A Python Developer’s Migration Guide
I’ll be honest. I fought the pyproject.toml migration for almost two years.
It felt unnecessary. *”If it ain’t broke, don’t fix it,”* right? My setup.py files worked fine. I knew exactly where every configuration lived. Then I inherited a project with seven different configuration files, each doing something slightly different. `setup.py`, `requirements.txt`, `setup.cfg`, `tox.ini`, `pytest.ini`, `.flake8`…
Build a Custom AI Terminal Assistant with Python: A Complete Step-by-Step Developer Tutorial
Build a Custom AI Terminal Assistant with Python: A Complete Step-by-Step Developer Tutorial I spend way too much… ...
That’s when the pain hit. Real pain.
I migrated everything to a single `pyproject.toml` file in one afternoon. And here’s exactly how you can do it too.
Top 3 Software Outsourcing Companies in Vietnam: 2026 Ratings
Looking for a reliable vietnam outsourcing partner? This guide provides an honest comparison of the Top 3 software… ...
Why pyproject.toml Won (And Why setup.py Lost)
The Python ecosystem has been moving toward PEP 621 for years. The goal is simple: standardize project metadata in one place. No more guessing which file holds your package name or dependencies.
Here’s what you gain:
- Unified config — One file for your build system, linter, formatter, and test runner
- Deterministic builds — No more `setup.py` executing arbitrary code during installation
- Tool interoperability — Black, Ruff, mypy, pytest all read from the same file
- Cleaner dependency management — PEP 508 compliant, no more parsing `requirements.txt` manually
Don’t believe me? Let’s compare.
The Old Way (setup.py + friends)
python
# setup.py
from setuptools import setup, find_packages
setup(
name="my-cool-project",
version="0.1.0",
packages=find_packages(),
install_requires=[
"fastapi>=0.100.0",
"uvicorn[standard]>=0.23.0",
"sqlalchemy>=2.0.0",
"pydantic>=2.0.0",
],
extras_require={
"dev": ["pytest>=7.0.0", "black>=23.0.0", "ruff>=0.1.0"],
"test": ["pytest>=7.0.0", "coverage>=7.0.0"],
},
python_requires=">=3.11",
)
That’s fine. But then you also need:
- `requirements-dev.txt`
- `setup.cfg` (if you’re fancy)
- `pyproject.toml` anyway (for build tools)
It’s fragmented. Honestly, it’s a mess.
The New Way (Single pyproject.toml)
toml
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "my-cool-project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.100.0",
"uvicorn[standard]>=0.23.0",
"sqlalchemy>=2.0.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"black>=23.0.0",
"ruff>=0.1.0",
]
test = [
"pytest>=7.0.0",
"coverage>=7.0.0",
]
[tool.black]
line-length = 100
target-version = ["py311"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
Clean. Complete. One file.
The Step-by-Step Migration Guide
If you’re ready to migrate an existing project, here’s the exact process I use.
Step 1: Audit Your Current Configuration
First, list every config file you currently use. I bet it’s more than you think.
bash
find . -maxdepth 1 -type f | grep -E '\.(cfg|ini|toml|txt|flake8)$'
You’ll probably see: `setup.py`, `setup.cfg`, `requirements.txt`, `tox.ini`, `pytest.ini`, `.flake8`, `mypy.ini`.
Step 2: Copy All Metadata Into pyproject.toml
Take everything from `setup.py`’s `setup()` call and move it under the `[project]` table. The keys map almost directly:
| setup.py parameter | pyproject.toml key |
|---|---|
| `name` | `[project].name` |
| `version` | `[project].version` |
| `description` | `[project].description` |
| `install_requires` | `[project].dependencies` |
| `extras_require` | `[project].optional-dependencies` |
| `python_requires` | `[project].requires-python` |
| `packages` | `[tool.setuptools.packages.find]` |
Step 3: Move Tool Configs Under Their Respective Tables
Each tool has a dedicated section. The pattern is always `[tool.
toml
[tool.black]
line-length = 100
[tool.ruff]
# Ruff configuration here
[tool.mypy]
python_version = "3.11"
strict = true
Pro tip: Check each tool’s documentation for the exact keys. Most modern tools (Ruff, Black, mypy, pytest) fully support pyproject.toml now.
Step 4: Replace setup.py with a No-Op
You can keep a minimal `setup.py` for backwards compatibility:
python
from setuptools import setup
setup()
Or delete it entirely if you’re not using `pip install -e .` (which still works with pyproject.toml).
Step 5: Test Your Build
bash
pip install -e .
python -c "import my_cool_project; print('It works!')"
Then run your tests:
bash
pytest
ruff check .
black --check .
If everything passes, you’re done. Delete the old config files.
Handling Dynamic Dependencies (The Tricky Part)
Not everything is straightforward. Sometimes you need dynamic dependencies based on the environment.
Old setup.py:
python
import sys
if sys.platform == "win32":
install_requires.append("pywin32>=300")
In pyproject.toml, you handle this with environment markers:
toml
[project]
dependencies = [
"pywin32>=300; platform_system == 'Windows'",
]
PEP 508 environment markers are powerful. Use `os_name`, `sys_platform`, `platform_machine`, `python_version`, and more.
What About Poetry and PDM?
You don’t have to use setuptools. Poetry and PDM have their own `pyproject.toml` conventions, but the standard `[project]` table is the same.
Poetry example:
toml
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "my-cool-project"
version = "0.1.0"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.100.0"
The key difference: Poetry uses `[tool.poetry]` for metadata instead of `[project]`. But PEP 621 compliant tools use `[project]` directly. Pick one ecosystem and stick with it.
Real Talk: What I Learned from This Migration
Recently, we onboarded a new developer on a project I’d already migrated. She opened the repo and said, *”Oh, this is clean. Where’s the config?”*
That’s the point.
When you hand a pyproject.toml project to a remote team — like the talented engineers we work with in Ho Chi Minh City — there’s zero friction. No one asks *”Which file do I edit for the linter?”* It’s obvious.
I’ve seen this firsthand with our extended teams at ECOA AI. When we ship a Python project to one of our Vietnamese development teams, the first thing they check is the `pyproject.toml`. It tells them everything they need to know about the project’s standards. No guesswork.
When Should You NOT Use pyproject.toml?
To be fair, there are edge cases:
- Legacy projects stuck on Python 3.8 or older — Some tools don’t support pyproject.toml fully on older Python versions
- C-extensions with complex builds — If you’re doing weird things with `setuptools.Extension`, you might need `setup.py` for now
- Extremely complex custom build steps — The `[tool.setuptools]` hooks are improving, but some edge cases still need a setup.py fallback
But honestly? 95% of projects can migrate today. The other 5% are edge cases you’ll know when you see.
Your Migration Checklist
Here’s a quick reference for your next migration:
- Move all `setup()` arguments to `[project]` table
- Convert `extras_require` to `[project.optional-dependencies]`
- Replace `requirements.txt` with `[project.dependencies]`
- Move linter configs to `[tool.
]` sections - Add `[build-system]` table
- Test with `pip install -e .`
- Run full test suite
- Delete old config files
- Update README with new instructions
- Celebrate that you never have to edit a setup.py again
Frequently Asked Questions
How do I migrate from setup.py to pyproject.toml for an existing project?
Start by creating a `pyproject.toml` file with a `[build-system]` table (using `setuptools` as the backend). Copy all metadata from your `setup()` call into the `[project]` table. Move tool configurations under `[tool.toolname]` sections. Test with `pip install -e .`, then delete the old config files.
Can I still use setuptools with pyproject.toml?
Yes. Setuptools fully supports `pyproject.toml` as a configuration source. You can remove `setup.py` entirely and let setuptools read from `pyproject.toml`. Just set `build-backend = “setuptools.backends._legacy:_Backend”` in your `[build-system]` table.
Do I need to change my project structure for pyproject.toml?
No. Your project structure stays the same. `pyproject.toml` only replaces the configuration files. Your `src/` layout, tests, and module organization remain unchanged. The migration is purely about config consolidation.
How do I add entry points (CLI scripts) with pyproject.toml?
Use the `[project.scripts]` table. For example: `[project.scripts]\nmy-cli = “my_package.cli:main”`. This replaces the `entry_points` parameter in `setup.py`. It works identically for console scripts and GUI scripts.
Related reading: Why Outsourcing Software Development in Vietnam Is the Smartest Move for Your Startup (and Your Sanity)
Related: offshore team in Vietnam — Learn more about how ECOA AI can help your team.
Related: Vietnam software outsourcing — Learn more about how ECOA AI can help your team.
Related: Vietnam offshore development — Learn more about how ECOA AI can help your team.
Related: Vietnam outsourcing — Learn more about how ECOA AI can help your team.
Related reading: Why You Should Hire Vietnamese Developers: A No-Nonsense Strategy Guide