How to migrate from setup.py to pyproject.toml
Move static package metadata from setup.py into pyproject.toml using the PEP 621 project metadata standard. Keep a setup.py shim for build logic that cannot be expressed declaratively.
This guide covers projects that use setuptools as their build backend. If a project uses Poetry, see How to migrate from Poetry to uv.
Migrate the project configuration
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 and check that it produces a wheel:
$ uv build
Create a separate environment for the built wheel. The --python option targets that environment; --no-project prevents uv run from syncing an editable copy of the project over it:
$ uv venv .venv-wheel-check
$ uv pip install --python .venv-wheel-check dist/my_package-1.0.0-py3-none-any.whl
$ uv run --no-project --python .venv-wheel-check python -c "import my_package; from importlib.metadata import version; print(version('my-package'))"
1.0.0
This checks that the wheel installs and its package imports. Inspect the built wheel’s data files or run release smoke tests in the isolated environment if those are part of the package contract.
If the project has tests, select the dev extra from the converted configuration:
uv run --extra dev pytestThis runs tests in uv’s project environment, normally using an editable installation. It does not test the wheel installed in .venv-wheel-check.
6. Remove legacy files
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.
Switch 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"]Consult the backend’s documentation for source-layout and build settings. See What is a build backend? for guidance on choosing a backend.