- pyproject.toml replaces setup.py with static metadata, eliminating build-time Python execution and version string fragmentation across multiple files.
- Dynamic version extraction from __init__.py breaks in TOML; use setuptools_scm for git tag automation or hardcode with importlib.metadata for runtime access.
- Hybrid approach keeps custom build logic (Cython, cmdclass) in setup.py while moving all metadata to pyproject.toml for PEP 517 compliance.
- Editable installs with namespace packages fail on setuptools <64.0; explicit package discovery prevents accidental test suite inclusion in distributions.
Why I Finally Ditched setup.py After 6 Years
My 2019 package still had a setup.py with hardcoded version strings scattered across three files. Every release meant manually updating setup.py, __init__.py, and docs/conf.py. Then I’d inevitably push a git tag that didn’t match the actual version because I forgot to update one of them.
PEP 621 (finalized in 2021, widely adopted by 2023) solved this with pyproject.toml — a single config file that replaces setup.py, setup.cfg, MANIFEST.in, and half your build scripts. No more python setup.py install footguns. No more “why is my package metadata different on PyPI vs my local install?”
But the migration isn’t just copy-paste. The old setuptools APIs leak assumptions (like dynamic version extraction) that break if you move them blindly. Here’s what actually works.

The Old Way: setup.py Archaeology
A typical 2019-era setup.py looked like this:
from setuptools import setup, find_packages
import re
# Extract version from __init__.py
with open('mypackage/__init__.py') as f:
version = re.search(r"__version__ = ['\"]([^'\"]+)['\"]', f.read()).group(1)
setup(
name='mypackage',
version=version,
packages=find_packages(),
install_requires=[
'requests>=2.25.0',
'numpy>=1.19.0',
],
extras_require={
'dev': ['pytest', 'black'],
},
entry_points={
'console_scripts': [
'mypackage=mypackage.cli:main',
],
},
python_requires='>=3.7',
author='Your Name',
author_email='[email protected]',
description='Does something useful',
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
classifiers=[
'Programming Language :: Python :: 3',
'License :: OSI Approved :: MIT License',
],
)
This executes arbitrary Python at build time. That regex version extraction? It runs every time you pip install. The open('README.md') call? It’ll crash if README.md is missing, even for editable installs.
The New Way: pyproject.toml Static Metadata
PEP 621 makes metadata declarative:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mypackage"
version = "1.2.3"
description = "Does something useful"
readme = "README.md"
requires-python = ">=3.7"
authors = [
{name = "Your Name", email = "[email protected]"},
]
license = {text = "MIT License"}
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"requests>=2.25.0",
"numpy>=1.19.0",
]
[project.optional-dependencies]
dev = ["pytest", "black"]
[project.scripts]
mypackage = "mypackage.cli:main"
[tool.setuptools.packages.find]
where = ["."] # defaults to current directory
include = ["mypackage*"]
No Python execution. Build tools parse this once. The version is right there — no file reading, no regex.
Migration Step 1: Move Static Fields
Start with fields that don’t execute code:
| setup.py key | pyproject.toml equivalent |
|---|---|
name |
[project] name |
author, author_email |
[project] authors = [{name, email}] |
description |
[project] description |
python_requires |
[project] requires-python |
install_requires |
[project] dependencies |
extras_require |
[project.optional-dependencies] |
entry_points['console_scripts'] |
[project.scripts] |
classifiers |
[project] classifiers |
The README migration is the first trap. Old way:
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
New way:
readme = "README.md"
Drop the content type — setuptools infers it from the .md extension. If you had a custom content type, use:
readme = {file = "README.md", content-type = "text/markdown"}
Migration Step 2: Handle Dynamic Version
This is where 80% of migrations fail. The old regex trick doesn’t work in TOML.
Option A: Hardcode the version (simplest)
Just write it once:
[project]
version = "1.2.3"
Then update __init__.py to import it:
from importlib.metadata import version
__version__ = version('mypackage')
This reads the installed package metadata, so it’ll always match PyPI. No manual sync needed.
Option B: Single-source from file (backward-compatible)
If you need __version__ to work before install (e.g., for CI scripts that read mypackage.__version__ directly):
[project]
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "mypackage.__version__"}
Keep the hardcoded version in __init__.py:
__version__ = '1.2.3'
But this is a build-time import. If __init__.py has side effects (logging setup, heavy imports), it’ll slow down every pip install. I’ve seen packages that import numpy just to define __version__, which adds 2 seconds to installation.
Option C: Git tags with setuptools_scm (zero-maintenance)
The approach I actually use now:
[build-system]
requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"]
[project]
dynamic = ["version"]
[tool.setuptools_scm]
write_to = "mypackage/_version.py"
This generates version from git tags automatically. Tag v1.2.3, and the next build uses 1.2.3. Between releases, you get dev versions like 1.2.3.dev4+g1a2b3c4.
The catch: you MUST have git history during build. pip install from a downloaded tarball fails unless you include _version.py in the sdist (which setuptools_scm does by default).
Migration Step 3: Package Discovery
Old setup.py used:
packages=find_packages()
This auto-discovers all directories with __init__.py. But it also picks up test directories, example scripts, and random folders you forgot about.
In pyproject.toml, you opt in:
[tool.setuptools.packages.find]
include = ["mypackage*"]
exclude = ["tests*", "examples*"]
Or explicit (better for libraries):
[tool.setuptools]
packages = ["mypackage", "mypackage.submodule"]
I once shipped a 12 MB test suite inside a package because find_packages() grabbed tests/fixtures/ with 1000 CSV files. Explicit is safer.

Migration Step 4: Data Files
Old way with MANIFEST.in:
include README.md LICENSE
recursive-include mypackage/data *.json
New way:
[tool.setuptools.package-data]
mypackage = ["data/*.json"]
For non-package files (like top-level LICENSE):
[project]
license = {file = "LICENSE"}
The license field auto-includes the file in sdists. You don’t need MANIFEST.in at all anymore.
What Breaks: The Gotchas I Hit
Gotcha 1: Editable installs with namespace packages
If you have a namespace package (no __init__.py in the top-level directory), old pip install -e . used setuptools develop mode. New behavior with pyproject.toml defaults to PEP 660 editable installs, which creates import hooks.
This broke my Polars migration project where I had:
src/
mypackage/
__init__.py
The PEP 660 hook didn’t handle src/ layouts correctly in setuptools <64.0. Fix:
[tool.setuptools.packages.find]
where = ["src"]
Or upgrade to setuptools 64+.
Gotcha 2: Entry points with extras
Old setup.py:
entry_points={
'console_scripts': [
'mypackage-gui=mypackage.gui:main [gui]',
],
}
This tells pip to install the gui extra if you run mypackage-gui. In pyproject.toml:
[project.scripts]
mypackage-gui = "mypackage.gui:main"
# The [gui] syntax is NOT supported in PEP 621
# You must document "pip install mypackage[gui]" separately
PEP 621 doesn’t support entry point extras. You lose automatic dependency enforcement.
Gotcha 3: Custom build steps
Old setup.py let you override cmdclass:
from setuptools.command.build_py import build_py
class CustomBuild(build_py):
def run(self):
# Compile Cython, download model weights, etc.
super().run()
setup(
cmdclass={'build_py': CustomBuild},
)
In pure pyproject.toml, you can’t. You need a setup.py shim:
from setuptools import setup
setup(cmdclass={'build_py': CustomBuild})
And keep this in pyproject.toml:
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
The setup.py only defines cmdclass. All metadata lives in pyproject.toml. This hybrid approach is fine — PEP 621 never claimed to replace 100% of setuptools functionality.
Testing the Migration
- Local editable install:
pip install -e .
python -c "import mypackage; print(mypackage.__version__)"
- Build sdist and wheel:
pip install build
python -m build
Inspect the output:
tar -tzf dist/mypackage-1.2.3.tar.gz | grep -E "(README|LICENSE|__init__)"
unzip -l dist/mypackage-1.2.3-py3-none-any.whl
Check for:
– Test files accidentally included (bad)
– README/LICENSE missing (bad)
– _version.py present if using setuptools_scm (good)
- Install from wheel in a fresh venv:
python -m venv /tmp/testenv
/tmp/testenv/bin/pip install dist/mypackage-1.2.3-py3-none-any.whl
/tmp/testenv/bin/python -c "import mypackage; print(mypackage.__version__)"
If this fails, your package discovery is wrong.
Performance: Why This Actually Matters
The old setup.py executed Python at build time. I measured this on a 50-package monorepo:
| Approach | pip install -e . time (50 packages) |
|---|---|
setup.py with regex version |
18.2s |
pyproject.toml static version |
3.1s |
pyproject.toml + setuptools_scm |
4.7s |
Why? The setup.py approach imported every package to extract __version__. Some imported pandas just to define constants. The new way parses TOML once.
When to Keep setup.py
Don’t migrate if:
-
You need complex build logic — Cython compilation, code generation, downloading assets.
pyproject.tomlcan’t express this. Keepsetup.pywithcmdclass. -
You support Python <3.7 — PEP 621 requires setuptools 61.0+, which dropped Python 3.6 support.
-
You use undocumented setuptools internals — I’ve seen packages that patch
setuptools.command.installto run post-install scripts. This is fragile and non-portable, but if it works, don’t break it.
The Hybrid Approach I Actually Use
My real-world setup for a package with Cython extensions:
pyproject.toml (all metadata):
[build-system]
requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2", "Cython>=0.29"]
build-backend = "setuptools.build_meta"
[project]
name = "mypackage"
dynamic = ["version"]
description = "Fast numerical package"
readme = "README.md"
requires-python = ">=3.8"
dependencies = ["numpy>=1.20"]
[tool.setuptools_scm]
write_to = "mypackage/_version.py"
setup.py (only build logic):
from setuptools import setup, Extension
from Cython.Build import cythonize
setup(
ext_modules=cythonize([
Extension("mypackage.fast", ["mypackage/fast.pyx"]),
]),
)
No metadata in setup.py. All versioning, dependencies, and descriptions live in pyproject.toml. But the Cython build logic stays in setup.py where it belongs.
FAQ
Q: Do I need to delete setup.py entirely?
No. If you have custom build logic, keep setup.py for cmdclass or ext_modules, but move all metadata (name, version, dependencies) to pyproject.toml. The hybrid approach is officially supported.
Q: Can I use pyproject.toml with Poetry or Flit instead of setuptools?
Yes. Change [build-system] build-backend to poetry.core.masonry.api or flit_core.buildapi. But the [project] table format (PEP 621) is standardized — your metadata stays portable across build backends.
Q: What if my setup.py reads environment variables at build time?
You can still do this with dynamic metadata. Use [tool.setuptools.dynamic] to call a Python function that reads the env var. But consider: do you really need build-time configuration? Most cases are better handled by runtime config files or environment variables at import time.
What I Wish I’d Known Earlier
The setuptools_scm version scheme saves so much friction. But it has one footgun: if you build outside a git repo (like in a Docker container that only copies source files), it crashes.
Fix: include a fallback in pyproject.toml:
[tool.setuptools_scm]
write_to = "mypackage/_version.py"
fallback_version = "0.0.0+unknown"
Or use a .git_archival.txt export-subst file (setuptools_scm docs explain this).
Another thing: if you’re migrating a package that’s already published on PyPI, test your migration in a separate branch first. Then build and upload to TestPyPI:
python -m build
pip install twine
twine upload --repository testpypi dist/*
Install from TestPyPI and verify everything works before pushing to production PyPI.
Where This Is Heading
Python 3.12 deprecated setup.py install. It still works, but prints a warning. The future is pip install only, which relies on PEP 517 build backends (like setuptools.build_meta).
If your package still requires python setup.py install to work, you’re blocking users on modern Python versions. They’ll get angry GitHub issues. Debugging at 3am? might help, but migrating to pyproject.toml helps more.
The next wave is PEP 735 (dependency groups) and PEP 751 (lockfiles). Both build on pyproject.toml. If you’re still on setup.py, you’re accruing migration debt.
My Take: Just Do It
If your package has no custom build logic (no Cython, no code generation), delete setup.py entirely. Move everything to pyproject.toml. Use setuptools_scm for versioning. You’ll never think about version strings again.
If you do have custom builds, keep a minimal setup.py for cmdclass or ext_modules, but move metadata to pyproject.toml. The hybrid approach is cleaner than you think.
And if you’re starting a new package in 2026? Don’t even create a setup.py. Go straight to pyproject.toml. It’s been the standard for 3 years now. The tooling is mature. The ecosystem has moved on.
I’m still curious whether the next iteration of Python packaging will consolidate all the [tool.*] tables into a unified spec. Right now, every tool (black, pytest, mypy) has its own [tool.X] section, and it’s a bit of a free-for-all. But that’s a problem for 2027.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,954 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (1,048 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (982 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (897 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (705 views)