How to migrate from setup.py to pyproject.toml
The setup.py file was long the dominant way to configure a Python package. PEP 621 changed that by standardizing project metadata in pyproject.toml, and every major build backend now supports it. Migrating removes the need for executable configuration files, makes metadata readable by any tool, and aligns a project with current packaging standards.
This guide covers projects that use setuptools as their build backend. If a project uses Poetry, see How to migrate from Poetry to uv.
Steps
1. Map setup.py fields to pyproject.toml
Most setup() arguments have a direct equivalent in [project] metadata. Here is a typical conversion:
Before (setup.py):
from setuptools import setup, find_packages
setup(
name="my-package",
version="1.0.0",
description="A short description",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
author="Your Name",
author_email="you@example.com",
url="https://github.com/you/my-package",
license="MIT",
python_requires=">=3.9",
install_requires=[
"requests>=2.28",
"click>=8.0",
],
extras_require={
"dev": ["pytest>=7.0", "ruff"],
},
packages=find_packages(where="src"),
package_dir={"": "src"},
entry_points={
"console_scripts": [
"my-cli=my_package.cli:main",
],
},
)After (pyproject.toml):
[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
version = "1.0.0"
description = "A short description"
readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
authors = [
{ name = "Your Name", email = "you@example.com" },
]
dependencies = [
"requests>=2.28",
"click>=8.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "ruff"]
[project.urls]
Homepage = "https://github.com/you/my-package"
[project.scripts]
my-cli = "my_package.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
namespaces = falseSetting namespaces = false matches the behavior of find_packages(), which excludes implicit namespace packages. Without it, setuptools scans for namespace packages by default, which can change which directories are included.
The full field mapping:
| setup.py | pyproject.toml |
|---|---|
name | [project] name |
version | [project] version |
description | [project] description |
long_description + long_description_content_type | [project] readme |
author, author_email | [project] authors |
url | [project.urls] |
license | [project] license |
python_requires | [project] requires-python |
install_requires | [project] dependencies |
extras_require | [project.optional-dependencies] |
entry_points["console_scripts"] | [project.scripts] |
packages with find_packages() | [tool.setuptools.packages.find] |
package_dir | [tool.setuptools.package-dir] |
2. Create the pyproject.toml file
Create pyproject.toml in the project root and add the converted configuration from step 1. Always include the [build-system] table so build tools know which backend to use:
[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"Tip
Setuptools 77.0.3+ is recommended for full [project] table support, including SPDX license strings like license = "MIT" (per PEP 639). Earlier versions (61.0+) support most of the [project] table but require the older license = {text = "MIT"} format.
3. Handle setup.cfg (if present)
Some projects split configuration between setup.py and setup.cfg. The setup.cfg fields map to the same [project] keys shown above. Move the packaging metadata into pyproject.toml.
If the project uses setup.cfg for tool configuration (like [tool:pytest]), move those to the corresponding [tool.*] sections in pyproject.toml where possible. Not all tools support pyproject.toml configuration; for example, flake8 only reads from .flake8, setup.cfg, or tox.ini. Check each tool’s documentation before migrating its config. If any tool still requires setup.cfg, keep the file with only those sections.
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 884. Handle dynamic metadata
Some projects compute the version at build time from a VCS tag or a module attribute. Setuptools supports this through the dynamic field:
[project]
dynamic = ["version"]
[tool.setuptools.dynamic]
version = { attr = "my_package.__version__" }For VCS-based versioning with setuptools-scm:
[build-system]
requires = ["setuptools>=77.0.3", "setuptools-scm>=8.0"]
build-backend = "setuptools.build_meta"
[project]
dynamic = ["version"]
[tool.setuptools_scm]See How to add dynamic versioning to uv projects for a complete walkthrough.
5. Verify the migration
Build the package to confirm the metadata is correct:
$ uv build
Install the package in a virtual environment and verify the metadata looks correct:
$ uv pip install dist/my_package-1.0.0-py3-none-any.whl
$ uv run python -c "
from importlib.metadata import metadata
m = metadata('my-package')
print(m['Name'], m['Version'])
print('Requires:', m.get_all('Requires-Dist'))
"
If the project has tests, run them to catch any packaging issues:
$ uv run pytest
6. Remove legacy files
Once everything works, delete the files that are no longer needed:
If setup.py contains only a setup() call with static arguments (no programmatic logic), delete it. If it contains conditional logic, custom commands, or other programmatic configuration, keep it alongside pyproject.toml. Running python setup.py commands directly is deprecated, but setup.py is still a valid setuptools configuration file when programmatic configuration is needed.
Delete setup.cfg if all of its configuration has been moved to pyproject.toml or other config files.
Important
MANIFEST.in controls which non-package files (data files, documentation, configuration) are included in source distributions. pyproject.toml does not replace it. Before deleting MANIFEST.in, run uv build and inspect the sdist contents with tar tf dist/*.tar.gz to confirm all necessary files are included. If the project includes data files, documentation, or other non-Python assets in the sdist, keep MANIFEST.in.
Switching to a different build backend
Migrating from setup.py to pyproject.toml does not require staying with setuptools. The [project] table is defined by PEP 621 and supported by most major build backends. To switch to hatchling, for example, change the [build-system] table and replace any setuptools-specific configuration:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"The [project] metadata stays the same, but backend-specific tables like [tool.setuptools.packages.find] must be replaced with the new backend’s equivalents. For a src layout with hatchling, replace the setuptools package-finding config with:
[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]Each backend has its own conventions for source layout and build configuration. Consult the backend’s documentation for details. See What is a build backend? for guidance on choosing a backend.
Learn More
Get Python tooling updates
Subscribe to the newsletter