setup.py to pyproject.toml: PEP 621 Migration Guide

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • 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.

A developer typing code on a laptop with a Python book beside in an office.
Photo by Christina Morillo on Pexels

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.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

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.

Reticulated python coiled up in a naturalistic enclosure with textured background depicting a serene habitat.
Photo by Pon Thhao on Pexels

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

  1. Local editable install:
pip install -e .
python -c "import mypackage; print(mypackage.__version__)"
  1. 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)

  1. 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:

  1. You need complex build logic — Cython compilation, code generation, downloading assets. pyproject.toml can’t express this. Keep setup.py with cmdclass.

  2. You support Python <3.7 — PEP 621 requires setuptools 61.0+, which dropped Python 3.6 support.

  3. You use undocumented setuptools internals — I’ve seen packages that patch setuptools.command.install to 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 coffee
TODAY 389 | TOTAL 146,921