Why can a PEP 517 build fail on an import that works in your active virtualenv?
answer
- The build does not run where you think
- A fresh environment, built from one list
- Your activated environment is invisible to it
- Only [build-system] requires is installed
- --no-build-isolation makes you the installer
basics
~20 sBecause the build runs in a fresh, isolated environment containing only the [build-system] requires list. Your activated environment is not on the build's import path, so anything the backend needs must be declared, not merely installed nearby.
solid answer
~50 sBy default a frontend builds in **isolation**: it creates a throwaway environment, installs exactly the `[build-system] requires` entries into it, and runs the backend there. Your project's runtime dependencies and whatever you happen to have installed in the activated environment are invisible. So a backend plugin that a developer installed by hand months ago works on that laptop and fails in CI with a `ModuleNotFoundError` the moment the build runs somewhere clean — the build had an unstated ordering assumption about what was installed first. The fix is almost always to add the missing requirement to `requires`. `--no-build-isolation` (`--no-isolation` for `build`) turns isolation off and is legitimate for air-gapped builds, pre-release or locally built build tools, and builds that must link against the exact library already present — but then you are responsible for installing every build requirement yourself.
code
console · 2 lines$ python -m pip install "setuptools>=77"
$ python -m pip install --no-build-isolation .go deeper
Remember that a build happens somewhere other than your activated environment, and that build tools have to be listed in the project file rather than just installed on your machine.
Explain the mechanics: a temporary environment, populated only from [build-system] requires, in which the backend is imported and its hooks are called — and the ModuleNotFoundError that follows a missing entry.
Diagnose the works-here-fails-in-CI case from the traceback, know the legitimate reasons to disable isolation, and be explicit that disabling it to turn a build green is the wrong fix.
Own build reproducibility as policy: bounds in requires, centrally applied constraints for build tooling, and a stance on offline or air-gapped builds so teams are not each inventing their own opt-out.
## What isolation actually does When a frontend builds a source tree, PEP 517 says it should do so in an environment it controls. Concretely, pip creates a temporary environment, installs the `[build-system] requires` distributions into it, imports the backend *there*, and calls `build_wheel`. `python -m build` does the same. Two consequences follow, and both surprise people: 1. **Your activated environment is not on the build's path.** A package you installed by hand is not importable from the backend. Neither are your project's own runtime dependencies — `[project] dependencies` describe the *installed* package, and nothing installs them before the build. 2. **The build environment is fresh each time.** Everything in `requires` is resolved and installed again, so a cold build pays real wall-clock cost — on a cold wheel cache a modest project can spend forty-five seconds before the backend runs at all. Warm caches make that mostly disappear, but a locked-down CI image with no cache pays it on every job. ## The failure this produces The classic shape: a telemetry collector's build derives its version from source control using a backend plugin. The maintainer installed that plugin into the working environment long ago, so `python -m build` succeeded on their machine for a year. The plugin was never added to `requires`. A new CI runner builds from a clean checkout and the backend raises `ModuleNotFoundError` for the plugin — the build had silently depended on an *ordering* assumption, that something else would install the plugin before the build ran. Isolation did not break the build; it exposed that the declaration was incomplete. The same shape appears with a code generator, a compiler-facing helper, or a backend plugin that reads extra configuration. The fix is to declare it: ```toml [build-system] requires = ["setuptools>=77", "a-version-plugin>=8"] build-backend = "setuptools.build_meta" ``` If the requirement can only be known after inspecting the tree, that is exactly what the optional `get_requires_for_build_wheel` hook is for — the backend returns extra requirements and the frontend installs them into the same isolated environment. ## When turning isolation off is correct `pip install --no-build-isolation .` and `python -m build --no-isolation` are not hacks; they exist for real situations: * **Air-gapped or offline builds.** Isolation wants to *install* the requirements, which means reaching an index. With no network you must pre-install the build tools and disable isolation (or point pip at a local wheelhouse). * **A pre-release or locally patched build tool.** You want the backend you just built from a checkout, not the released one an isolated environment would fetch. * **Building against a specific already-installed library.** A source build of a compiled extension may need to link against the exact version of a library present in the environment; isolation would install a different one. * **Expensive build dependencies.** Where a build requirement itself has no wheel for the platform, resolving it from scratch every time can dominate the build. The cost of opting out is that you now own the build environment: every entry in `requires` must be installed by hand first, and the build silently uses whatever versions are there. In practice pair it with an explicit install step so the build is still reproducible: ```console $ python -m pip install "setuptools>=77" $ python -m pip install --no-build-isolation . ``` ## Controlling versions inside the isolated environment Isolation is not the same as pinning. Unless `requires` carries bounds, the isolated environment resolves the newest matching build tools each time, so a backend release can change your artifact without any change in your repository. Two levers: put honest lower bounds (and, for a release build you must reproduce, upper bounds) in `requires`; and apply constraints to the build environment — pip honours the `PIP_CONSTRAINT` environment variable when resolving build dependencies, which lets a platform team pin build tooling across many repositories without editing each `pyproject.toml`. ## Diagnosing it quickly When a build fails only in CI, ask three questions in order. Does the traceback come from *inside* the backend or from your own code being imported? A backend-side `ModuleNotFoundError` means a missing `requires` entry. Does the missing name appear anywhere in `requires`? If not, that is your answer. Does the build succeed with `--no-build-isolation` in an environment where the module is installed? If yes, the diagnosis is confirmed — and the *fix* is still to add the declaration, not to keep isolation off. Turning isolation off to make a red build green converts a clear, reproducible error into a machine-specific one, which is the single most common wrong response to this failure.
- Are your project's runtime dependencies available while the backend builds the wheel?No. `[project] dependencies` describe what the installer must provide alongside the *installed* package; nothing installs them before the build. If a backend plugin or a build script needs a library, it has to appear in `[build-system] requires`. This is why a `setup.py` that imports the project's own package to read a version constant fails under isolation — a good reason to keep build-time code from importing the thing being built.
- How would you keep build tooling consistent across many repositories without editing each pyproject.toml?Apply constraints to the build environment rather than pinning in every project. pip honours the `PIP_CONSTRAINT` environment variable when it resolves build dependencies, so a CI image can pin backend and plugin versions centrally while each repository keeps honest lower bounds in `requires`. That gives one place to roll a backend upgrade forward or back, and keeps individual projects from drifting onto whatever released this morning.
- A build fails in CI and passes locally. What tells you it is an isolation problem rather than a code problem?The traceback originates inside the backend or a build plugin, before any of your package's own code runs, and names a module that is not in `requires`. Confirm by reproducing in a clean container, or by checking whether the module is installed in your local environment but undeclared. Adding the requirement should fix both; if disabling isolation is the only thing that helps, the declaration is still what is wrong.
It is a clean-room assembly: only the parts on the delivery manifest get through the door, so a tool someone left on the bench outside is simply not there.
saying these in an interview costs you the question
- Says the active virtualenv is used for the build
- Thinks runtime dependencies are installed before building
- Reaches for --no-build-isolation as the standard fix
- Believes isolation pins build tool versions
- Cannot say where build requirements are declared
- Blames CI rather than an undeclared build requirement