skip to content

What does the [build-system] table in pyproject.toml tell a build frontend?

level: middleimportance: must knowfreq 55%

answer

  1. Two keys, one contract
  2. Who builds this, not what it is
  3. Frontend installs a list, imports an object
  4. PEP 518 requires, PEP 517 backend hooks
  5. build_wheel and build_sdist are the interface

basics

~10 s

It names who builds the project. requires lists build-time dependencies a frontend installs into a temporary environment; build-backend is the importable object the frontend calls to produce a wheel or a source distribution.

solid answer

~40 s

`[build-system]` is the PEP 518/517 contract between a *frontend* (pip, `build`, a publishing tool) and a *backend* (the library that actually assembles the artifact). `requires` is a list of PEP 508 requirement strings — the backend itself plus any build plugins — which the frontend installs into a fresh environment. `build-backend` is an importable object path such as `"setuptools.build_meta"`, `"hatchling.build"`, `"flit_core.buildapi"` or `"poetry.core.masonry.api"`; the frontend imports it and calls standard hooks — `build_wheel`, `build_sdist`, and `build_editable` for `pip install -e .`. Neither pip nor `build` knows anything about your project beyond those hooks, which is what makes backends swappable. If the table is missing entirely, modern pip falls back to legacy setuptools behaviour, which is a compatibility shim rather than a design.

code

python · 15 lines
python
import tomllib

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

table = tomllib.loads(src.decode())["build-system"]
print("install first:", table["requires"])

spec = table["build-backend"]
module, _, obj = spec.partition(":")
print("import module:", module)
print("call hooks on:", obj or module)

go deeper

for a junior

Recall that this table has exactly two common keys and that they concern building, not describing. Being able to point at build-backend and say "that is the library that makes the wheel" is enough at this level.

for a middle

Explain the frontend/backend split concretely: which requirement list is installed where, that build-backend is an importable object path, and that build_wheel and build_sdist are the hooks a frontend calls.

for a senior

Demonstrate control over reproducibility — lower bounds on build requirements, constraints applied to the build environment, and knowing that the legacy fallback silently picks a setuptools version for you.

for a principal

Own the standardisation argument: because the interface is hooks rather than a script, build tooling can be swapped or centralised across many repositories without touching consumers, and build policy becomes something a platform team can enforce.

## The frontend/backend split Before PEP 517 there was one build system: you ran `setup.py`, and every tool in the ecosystem hardcoded that assumption. PEP 518 added a place to declare *build-time* requirements (`[build-system] requires`), and PEP 517 added the second half: a named, importable **backend** with a documented set of hooks. The result is a clean division of labour. * A **frontend** is anything that wants an artifact: `pip` when it installs from a source tree or an sdist, `python -m build` when you produce files to publish, or a publishing tool wrapping either. A frontend never compiles or copies anything itself. * A **backend** is a library that knows how to turn *this* source tree into a wheel or sdist. It is ordinary Python code, installed like any other dependency. ## The two keys `requires` is a list of PEP 508 requirement strings, exactly like runtime dependencies but for build time only: the backend, plus any plugin the backend needs (a version-deriving plugin, a code generator, the compiler-facing helpers a source build of an extension needs). It must **not** contain your project's runtime dependencies — those live in `[project] dependencies` and are irrelevant while building. `build-backend` is an object reference in the form `module` or `module:object`. The frontend imports the module, takes the attribute after the colon if present, and calls hooks on it. Real examples, spelled exactly: ```toml [build-system] requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" ``` Other common values are `"hatchling.build"`, `"flit_core.buildapi"` and `"poetry.core.masonry.api"`. An optional third key, `backend-path`, points at a directory inside the project so a project can vendor an in-tree backend — how a build system bootstraps itself without being on an index. ## The hooks The interface is small and that is the point. Two hooks are mandatory: * `build_wheel(wheel_directory, config_settings=None, metadata_directory=None)` * `build_sdist(sdist_directory, config_settings=None)` Several are optional: `get_requires_for_build_wheel` lets a backend add *more* build requirements after inspecting the tree; `prepare_metadata_for_build_wheel` lets a resolver learn a project's dependencies without paying for a full build; and `build_editable` (PEP 660) is what `pip install -e .` calls, replacing the old `setup.py develop` path. A frontend passes `config_settings` through from the command line — that is how a `--config-settings` flag reaches a backend without pip understanding the option. ## Why swapping backends is cheap Because everything a consumer sees is the produced artifact, the choice of backend is an internal decision. A wheel built by one backend is indistinguishable to the installer from a wheel built by another; there is no runtime dependency on your backend. That is why moving a pure-Python library from setuptools to a lighter backend is a two-line diff in `[build-system]` plus moving whatever file-inclusion configuration lived under `[tool.<old backend>]`. In practice: setuptools is the compatible default and the one with an established path for compiled extensions and legacy configuration; the lighter backends trade some of that surface for simpler configuration and faster installs. Pick per project, not per organisation religion. ## Version bounds in requires `requires` is where reproducibility lives. `requires = ["setuptools"]` means your build depends on whatever the backend released this morning; a backend that changes a default can silently change what lands in your wheel. Put a lower bound on any behaviour you rely on, and consider constraining build dependencies centrally (pip honours the `PIP_CONSTRAINT` environment variable when it builds the isolated environment) rather than freezing the whole list, which would keep you from ever getting a fix. ## The missing-table case If a source tree has no `[build-system]` at all, a modern frontend does not give up — it assumes the legacy arrangement: an implicit setuptools requirement and the `setuptools.build_meta:__legacy__` backend, which tolerates a project whose metadata lives in `setup.py` or `setup.cfg`. That fallback exists for old projects and should not be relied on for a new one: it makes your build depend on whichever setuptools version happens to be resolved, and it hides the fact that you have made no declaration at all. ## What to say in an interview Name the two keys, say what each one *is* (a requirement list and an importable object), and name the hook that produces a wheel. Then make the separation explicit: `[project]` describes the package, `[build-system]` describes the build, `requires` is not your runtime dependency list, and the backend is an implementation detail your users never see.

  • What happens when a source tree has no [build-system] table at all?
    A modern frontend assumes the legacy arrangement: an implicit setuptools build requirement and the `setuptools.build_meta:__legacy__` backend, which tolerates metadata living in `setup.py` or `setup.cfg`. It is a compatibility shim, not a default worth keeping — your build then depends on whichever setuptools version happens to be resolved that day, and nothing in the repository records which build system you actually expect.
  • Which hook does pip call for an editable install, and why does that matter?
    `build_editable`, defined by PEP 660. Before it existed, an editable install meant running `setup.py develop`, a setuptools-only code path that no other backend could implement. With the hook standardised, any backend can support `pip install -e .` and the frontend stays ignorant of how the import hook or path file is arranged. A backend that does not implement it simply cannot be installed editable, and pip says so.
  • Do consumers of your package care which backend you chose?
    No. The backend runs only while the artifact is produced; it is never a dependency of the installed package. A wheel built by one backend is indistinguishable to the installer from a wheel built by another, so switching is a two-line change in [build-system] plus moving file-inclusion configuration from one tool table to the other. The choice is about your maintenance experience and what your project needs to build, not about your users.

The frontend is a customer who only knows how to say "build me one"; [build-system] is the note telling them which workshop to hire and which tools that workshop needs delivered first.

saying these in an interview costs you the question

  • Puts runtime dependencies in [build-system] requires
  • Thinks build-backend names a command to run
  • Believes the backend is installed alongside the package
  • Says pip always builds by executing setup.py
  • Leaves requires unbounded and calls the build reproducible
  • Cannot name a single PEP 517 hook

context