skip to content

How do you migrate a legacy setup.py/setup.cfg project to pyproject.toml?

level: middleimportance: should knowfreq 38%

answer

  1. Three generations of packaging in one repo
  2. Declare the build system before anything else
  3. Legacy keys rename onto the standard table
  4. install_requires becomes dependencies; strings become arrays
  5. Keep setup.py only for imperative build code

basics

~20 s

Add a [build-system] table, move setup.cfg metadata and options into [project] (install_requires becomes dependencies, python_requires becomes requires-python), move package discovery into the backend's tool table, and delete setup.py unless you still need imperative build logic.

solid answer

~50 s

Start by declaring the build: a `[build-system]` table with a bounded `requires` and an explicit `build-backend`, so the build stops relying on the legacy fallback. Then translate `setup.cfg` — `[metadata]` and `[options]` keys map onto `[project]` almost one for one, with `install_requires` becoming `dependencies`, `python_requires` becoming `requires-python`, and package discovery moving under the backend's own `[tool.…]` table. Delete `setup.py` once nothing imperative remains; keep a minimal one only if you genuinely need code at build time, such as declaring compiled extensions, and keep it free of imports of your own package. Verify by building and installing the artifact, then reading the installed metadata back with `importlib.metadata` — the answer is not "it built", it is "the metadata matches". Editable installs go through the standard hook now, so `pip install -e .` keeps working without `setup.py develop`.

code

python · 22 lines
python
import tomllib

src = b"""
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[project]
name = "sensor-collector"
version = "2.1.0"
requires-python = ">=3.11"
dependencies = ["packaging>=24"]
authors = [{ name = "Telemetry Team", email = "[email protected]" }]

[tool.setuptools.packages.find]
where = ["src"]
"""

cfg = tomllib.loads(src.decode())
print(cfg["project"]["dependencies"])       # was install_requires
print(cfg["project"]["requires-python"])    # was python_requires
print(cfg["tool"]["setuptools"]["packages"]["find"])

go deeper

for a junior

Know that the old files are setup.py and setup.cfg and that the modern equivalent is one declarative pyproject.toml, and that the project still installs with pip either way.

for a middle

Be able to map the legacy keys onto the standard ones from memory — install_requires, python_requires, long_description — and explain where package discovery configuration now lives.

for a senior

Show how you verify the migration: build, install into a clean environment, compare installed metadata and file listings, and sequence the change in revertible steps around a release.

for a principal

Own the rollout across many repositories: a shared target configuration, bounded build requirements, and a policy on which backend teams standardise on versus where deviation is justified.

## Three generations in one repository A long-lived project often carries all three packaging generations at once: a `setup.py` that calls `setup(...)`, a `setup.cfg` holding the same information declaratively but in setuptools' own dialect, and (perhaps) a `pyproject.toml` containing only tool configuration. Migration means collapsing that into one standard file, in an order that keeps the project installable at every step. ## Step 1 — declare the build system Before touching metadata, add the table that says who builds the project: ```toml [build-system] requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" ``` This alone is a real improvement: without it a frontend falls back to `setuptools.build_meta:__legacy__` with an implicit, unbounded setuptools requirement, so your build silently tracks whatever version resolves. Staying on setuptools during the migration is deliberate — change one thing at a time. Switching to a lighter backend such as `hatchling.build`, `flit_core.buildapi` or `poetry.core.masonry.api` is a separate, later decision, worth taking for a pure-Python project with simple file inclusion and worth declining while you still need setuptools' handling of compiled extensions or unusual layouts. ## Step 2 — translate the metadata The `setup.cfg` sections map onto `[project]` with a few renames worth memorising: | legacy | standard | | --- | --- | | `[metadata] name`, `version`, `description` | `[project] name`, `version`, `description` | | `[metadata] long_description` / `long_description_content_type` | `[project] readme` | | `[metadata] classifiers` (newline list) | `[project] classifiers` (TOML array) | | `[metadata] url`, `project_urls` | `[project] urls` | | `[options] install_requires` | `[project] dependencies` | | `[options] python_requires` | `[project] requires-python` | | `[options] packages`, `package_dir` | `[tool.setuptools.packages.find]` | Two shape changes catch people out. Legacy fields are newline-separated strings; the standard fields are TOML arrays, so every entry becomes a quoted list element. And authorship moves from flat `author`/`author_email` keys to an array of tables: `authors = [{ name = "…", email = "…" }]`. Also re-check the licence declaration while you are there: modern backends want an SPDX expression string plus a `license-files` glob rather than the old licence classifier convention, and a stale classifier is an easy thing to leave behind. ## Step 3 — decide the fate of setup.py Delete it if it does nothing but call `setup()` with arguments you have just moved. Keep a minimal one only where you need *code* at build time — most commonly to declare compiled extension modules, which have no declarative form. A surviving `setup.py` sits alongside `pyproject.toml`, contains only what must be imperative, and must never import the package it is building: under build isolation that package is not installed, so an import for the sake of reading a version constant fails immediately. Separately, stop *invoking* `setup.py`. `python setup.py sdist`, `bdist_wheel` and especially `install` are deprecated entry points into the build; drive the standard hooks through a frontend instead (`python -m build` to produce artifacts, `pip install .` to install). The file may survive as configuration; running it as a command should not. ## Step 4 — verify the artifact, not the diff Building successfully proves very little. Build the wheel and sdist, install into a clean environment, and read the metadata back: ```python import importlib.metadata as md meta = md.metadata("sensor-collector") print(meta["Name"], meta["Version"], meta.get("Requires-Python")) print(meta.get_all("Requires-Dist")) ``` Compare that against the same output from an artifact built before the change. Typical regressions this catches: a dependency lost because a newline list was translated by hand, a missing `requires-python` floor that now lets the package install on an interpreter it cannot run on, and non-code files that stopped being included because the old inclusion rules lived in the tooling you replaced. Check that `pip install -e .` still works too — editable installs now go through the standard editable hook rather than `setup.py develop`, which is what makes them possible at all for a project with no `setup.py`. ## Sequencing on a live project Do it in separate commits: build-system table first, then metadata translation, then removal of the legacy files. Each step is independently revertible, and each one can be checked by building and diffing the resulting metadata. On a project that publishes regularly, land the migration immediately after a release, not immediately before one, so the first artifact produced by the new configuration has a full cycle of use before anyone depends on it.

  • When is keeping a setup.py file still justified after the migration?
    When something at build time genuinely has to be code. The usual case is declaring compiled extension modules, which have no declarative equivalent, and occasionally a custom build step. Keep it minimal: metadata belongs in `[project]`, the file exists only for the imperative part, and it must not import the package being built — under build isolation that package is not installed, so such an import fails before the wheel is produced.
  • How do you prove the migration did not change the published package?
    Compare metadata, not diffs. Build the artifact before and after, install each into a clean environment, and read `Requires-Dist`, `Requires-Python`, `Version` and the file listing back with `importlib.metadata`. Hand-translating newline-separated legacy lists into TOML arrays is where dependencies quietly disappear, and file-inclusion rules that lived in the old tooling are where data files quietly disappear. Both show up in that comparison and nowhere else.
  • Should the migration also switch build backends?
    Not in the same change. Moving metadata to `[project]` while staying on setuptools is already a large enough diff to verify, and it is the step that buys the standardisation. Choosing a lighter backend afterwards is a separate decision, easy for a pure-Python project with simple file inclusion, and one to decline while you still depend on setuptools handling compiled extensions or an unusual source layout.

saying these in an interview costs you the question

  • Says pyproject.toml cannot coexist with setup.py
  • Keeps invoking python setup.py to build artifacts
  • Copies install_requires as a newline string into TOML
  • Imports the package being built inside setup.py
  • Declares the migration done because the build succeeded
  • Switches backend and metadata in a single unverified change

context