skip to content

Why must a C extension's compile-time dependencies be listed in [build-system] requires in pyproject.toml?

level: middleimportance: should knowfreq 35%

answer

  1. The build gets its own environment
  2. Runtime requirements are absent while compiling
  3. The compiler must see those headers
  4. Two lists, two different moments
  5. An unpinned build dependency drifts the ABI

basics

~20 s

A PEP 517 build runs in an isolated environment holding only what the build-system requires list names, so anything the compile step needs — the backend, a distribution shipping C headers, a code generator — must be listed there. Runtime dependencies are absent.

solid answer

~50 s

When an installer builds a project, it creates a throwaway environment, installs exactly the distributions named in `[build-system] requires`, imports the backend named by `build-backend`, and calls its hooks. The project's `[project] dependencies` are *not* in that environment — they are installed later, into the target environment, when the resulting wheel is installed. So a package whose extension includes headers shipped by another distribution, or whose sources are produced by a code generator, must name those in `requires`; otherwise every source build fails for everyone but the maintainer, whose machine happens to have them. The subtler hazard is version drift: the isolated environment resolves the newest permitted version at build time, so an extension can end up compiled against a newer C API than the version present at runtime, producing an import error or a crash. Pin build requirements deliberately, and keep the runtime bound consistent with what you compiled against.

code

console · 1 line
console
python -m pip install --no-build-isolation .

go deeper

for a junior

Know that a project declares two separate dependency lists in pyproject.toml: one for building it and one for running it, and that they are installed at different times into different environments.

for a middle

Explain the isolated build environment: what goes into it, what is deliberately missing, and why a package with a C extension usually needs extra entries there — headers and code generators the compiler must see.

for a senior

Demonstrate the ABI reasoning: an unpinned build requirement resolves fresh at build time, so state which version you compile against and keep the runtime bound consistent, then explain when turning isolation off is legitimate.

for a principal

Frame the policy: how tightly the organisation pins build inputs against reproducibility versus security updates, whether sdists must stay buildable on platforms you do not publish wheels for, and who owns that contract.

## What the table actually does `pyproject.toml` carries a `[build-system]` table with two keys: `requires`, a list of distributions needed to *build* the project, and `build-backend`, the importable object the frontend calls. A build frontend — pip installing an sdist, or a standalone build tool — creates a fresh, empty environment, installs everything in `requires` into it, imports the backend from inside it, and calls hooks such as the one that produces a wheel. That is PEP 517 build isolation, and PEP 518 is the reason the table exists at all. Isolation buys reproducibility: the build no longer depends on whatever happened to be installed in the environment the user ran the install from, and the project rather than the user decides which backend version compiles it. ## Why a compiled project's list is longer For a pure-Python project `requires` is usually just the backend. A project with a C extension has to put everything the *compiler* must see into the same list: ```toml [build-system] requires = [ "setuptools>=64", # the backend itself "header-provider>=2.1,<3", # ships the C headers this extension includes ] build-backend = "..." ``` (the second entry is a placeholder for whichever distribution actually ships the headers you include). Typical members of that list are: the backend; a distribution whose Python package directory contains `.h` files the extension includes; a code generator that turns annotated sources into C before the compiler runs; and version-pinning helpers. What is *not* in the list is anything only needed at run time. ## Build-time and run-time are two different lists `[project] dependencies` are recorded in the wheel's metadata and installed into the target environment when the wheel is installed. They are absent during the build. `[build-system] requires` are installed only for the build and vanish with the throwaway environment. A distribution needed at both times must appear in both places — that is not duplication, it is two genuinely different statements. The classic bug is a project that lists its header-providing dependency only under `[project] dependencies`. The maintainer never notices, because their development environment has it installed and their wheels are built from a machine that does too; but every user forced onto a source build gets a missing-header failure. ## The ABI hazard hiding in the isolation Isolation resolves fresh versions. If `requires` names a distribution that exposes a C API with only a lower bound, the build installs the newest release satisfying it and compiles your extension against that version's headers, struct layouts and symbol set. At run time the target environment may hold an older release. The extension then fails to import with a message about a binary incompatibility, or — worse — loads and misbehaves because a struct it reads is a different size than it was compiled against. Two disciplines keep this straight: - **Compile against the oldest version you claim to support**, using a bound in `requires`, and let the runtime dependency accept anything from that version upwards. Extensions are generally forward compatible with newer versions of a library that promises a stable C API, not backwards compatible with older ones. - **Where the dependency publishes its own guidance on build-time pinning, follow it**, because the C API compatibility promise is the dependency's to make, not yours to guess. Exact pins in `requires` make builds reproducible but age badly: they block security updates to the backend and can make your sdist unbuildable on a platform where that exact version has no wheel. A lower bound on the backend and a deliberate, documented bound on the C-API-exposing dependency is the usual balance. ## Turning isolation off `pip install --no-build-isolation` skips creating the environment and builds against whatever is already installed. It is the right tool in three situations: the build requirement itself has no wheel for this platform and you have installed it another way; you are building offline against a pre-populated environment; or you are a distribution packager who must build against system packages rather than index releases. The cost is that you now own the correctness of the build environment — nothing checks that `requires` is satisfied. ## What changed, and when Python 3.12 removed `distutils` from the standard library, so a build backend from the index is no longer optional for anyone: projects that relied on the stdlib copy must declare a backend that supplies its own. Driving a build by executing the project's `setup.py` directly is likewise no longer the supported path; the frontend calls the backend's hooks instead. Neither change is about compiled code specifically, but compiled projects are where an unmaintained build configuration bites first, because they are the ones that cannot fall back to just copying source files.

  • If a distribution is needed both to compile the extension and to import it at run time, where does it go?
    In both lists. `[build-system] requires` puts it in the isolated build environment so the compiler can find its headers; `[project] dependencies` records it in the wheel metadata so it is installed alongside the package. Neither list implies the other, and listing it in only one produces either a build failure for users or an import failure after install.
  • When is `--no-build-isolation` the right call rather than a workaround?
    When the build requirement cannot come from the index — no wheel for this architecture, an offline build host, or a distribution packager who must build against system packages. You then preinstall the build requirements yourself and accept responsibility for the environment being correct, since nothing verifies `requires` any more.
  • Why is an exact pin of the build backend usually a bad idea?
    It freezes your sdist against one release, so security and bug fixes in the backend never reach your builds, and the sdist becomes unbuildable anywhere that exact version has no wheel. A lower bound expressing the feature you actually need ages far better; save tight bounds for a dependency whose C API you compile against.

saying these in an interview costs you the question

  • Thinks runtime dependencies are available during the build
  • Puts a compile-time header package only in project dependencies
  • Believes build isolation guarantees the same versions every build
  • Pins every build requirement exactly and never revisits it
  • Uses --no-build-isolation to silence an error it does not understand

context