Skip to content

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 = false

Setting 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.pypyproject.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 = 88

4. 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
Last updated on

Please submit corrections and feedback...