Declaring native build dependencies in pyproject.toml

This page answers one question: build-system.requires can only name Python packages, yet your build needs GDAL headers, PROJ and a C++ compiler — so where does each of those get declared, and how do you make a source build fail with a useful message instead of a compiler error? It sits inside the Mastering pyproject.toml for Spatial Wheels section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the split, the early check and the documentation that goes with it.

Where each kind of build dependency is declared Python build tools such as the backend and Cython are declared in build-system requires and installed automatically. Native libraries and headers cannot be expressed there at all and must come from the environment, the container or a package manager. The compiler comes from the same place. The version requirement on the native libraries is expressed in the build file, where find_package or dependency can enforce it and fail early with a clear message. Python build tools [build-system] requires = [...] the backend, Cython, NumPy headers — installed automatically into the isolated env native libraries and headers not expressible here at all GDAL, PROJ, GEOS and their -dev packages come from the environment or the image the version requirement find_package(GDAL 3.8 CONFIG REQUIRED) stated in the build file, where it can fail early with a message naming what is missing the manifest declares what pip can install; the build file declares what the machine must already have

Context & Root Cause

build-system.requires describes an isolated Python environment the backend runs in. It is resolved by an installer that only knows about Python distributions, so there is no syntax for “GDAL 3.8 development headers” — and no mechanism by which one could be installed, since a wheel-based installer has no way to put a C library on the system.

That leaves a gap for a source build. A user running pip install geo-core on an unsupported platform gets the sdist, the backend runs, and the compile fails somewhere inside a header include with a message about gdal.h. Everything worked as designed, and the message is unhelpful. The remedy is not to make the manifest express something it cannot; it is to fail earlier, in the build file, with a message that names the requirement and points at the documentation.

Solution / Fix

This targets scikit-build-core 0.9+, CMake 3.28+, GDAL 3.8.x and PROJ 9.3.x.

1. Put the Python build tools where they belong

[build-system]
requires = [
  "scikit-build-core>=0.9",
  "Cython>=3.0",
  "numpy>=1.23",           # for headers; the runtime dependency is separate
]
build-backend = "scikit_build_core.build"

2. State the native requirement in the build file, with a version

find_package(GDAL 3.8 CONFIG QUIET)
if(NOT GDAL_FOUND)
  message(FATAL_ERROR
    "GDAL 3.8 or newer with development headers is required to build geo-core "
    "from source.\n"
    "  Debian/Ubuntu: apt install libgdal-dev\n"
    "  conda-forge:   conda install -c conda-forge gdal\n"
    "  Homebrew:      brew install gdal\n"
    "Prebuilt wheels are available for Linux, macOS and Windows — this error "
    "means pip fell back to a source build.")
endif()

3. Say the same thing in the documentation and the metadata

[project]
description = "Geospatial bindings (wheels bundle GDAL; source builds require GDAL >= 3.8)"

[project.urls]
"Build requirements" = "https://example.org/geo-core/building-from-source"

4. Make the source distribution actually buildable

[tool.scikit-build]
sdist.include = ["CMakeLists.txt", "cmake/*.cmake", "src/**", "ci/native-versions.lock"]

An sdist missing the build files cannot be compiled by anyone, which turns a source fallback from inconvenient into impossible.

Verification

# 1. The source build fails early and legibly without GDAL
docker run --rm -v "$PWD:/s" python:3.12-slim bash -c \
  "pip install /s 2>&1 | tail -12"
# expected: the FATAL_ERROR message naming GDAL, not a compiler error about gdal.h
# 2. The sdist contains everything a build needs
python -m build --sdist
tar tzf dist/*.tar.gz | grep -E 'CMakeLists.txt|src/.*\.c$' | head
# expected: the build files are present
# 3. It builds where the dependency is present
docker run --rm -v "$PWD:/s" condaforge/miniforge3 bash -c \
  "conda install -y -q -c conda-forge gdal proj compilers cmake ninja >/dev/null &&
   pip install /s && python -c 'import geo_core; print(\"built from source\")'"

The first check is the one users experience. Running it in CI on every change keeps the message accurate — a requirement that moves from 3.6 to 3.8 without the message following it is worse than no message at all.

Why the Error Message Is the Deliverable

For a package that publishes wheels for the common platforms, a source build only happens when something has already gone slightly wrong: an unsupported platform, an old interpreter, a policy that forbids binary wheels. The person hitting it is therefore already off the happy path, and the quality of the message determines whether they can help themselves.

What a user sees with and without an early dependency check Without a check, the build proceeds until a compiler cannot find gdal.h, producing hundreds of lines of output whose last error mentions a header rather than a package, with no indication of which version is needed or how to install it. With a check, the build stops in the configure step with a single message naming the library, the minimum version, the install command for three platforms and the fact that wheels exist. without a check src/_ext.c:12:10: fatal error: gdal.h: No such file or directory preceded by 400 lines of build output no version named no install command no hint that wheels exist outcome: an issue on your tracker with a check GDAL 3.8 or newer with development headers is required… stops in the configure step names the minimum version gives three install commands says prebuilt wheels exist outcome: they fix it themselves

The last line of the message — that wheels exist and this fallback means one was not matched — is the part most often omitted and the part that resolves the most cases. A user on Python 3.13 the week it is released, or on a platform you do not publish for, is usually better served by learning that than by installing a C toolchain.

Keeping the message honest is a small maintenance obligation. When the minimum GDAL version moves, the message has to move with it, and the CI check that exercises the failure path is what enforces that.

Optional Native Dependencies

Some native dependencies are genuinely optional — a format driver, a compression codec, a networking backend — and expressing that cleanly keeps a source build possible for users who do not need them.

Required and optional native dependencies expressed as build options Required dependencies are found with a REQUIRED find_package that fails the configure. Optional ones are found quietly and gate a compile definition, so the extension builds without them and reports at run time which capabilities are present. A build option lets a packager force an optional dependency on, turning a silent omission into a configure failure when it is genuinely needed. required find_package(GDAL 3.8 CONFIG REQUIRED) the build cannot proceed without it — fail in configure with a message GDAL, PROJ, and the C++ runtime belong here optional find_package(WebP QUIET) → target_compile_definitions the extension builds without it and reports the missing capability at run time a codec used by one driver belongs here forced on option(GEO_REQUIRE_WEBP "…" OFF) a packager who needs the capability can make its absence a configure failure which turns a silent omission into an explicit build contract

The third row exists because silent omission is the failure mode of optional dependencies. A distribution packager building your sdist without WebP produces a package whose users cannot open WebP-compressed rasters, with nothing anywhere saying so. A build option they can switch on converts that into a check, and exposing the resulting capability set at run time — the same provenance() pattern used for versions — lets a user confirm what they have.

Pitfalls & Alternatives

Putting a native library in build-system.requires. There is sometimes a similarly-named Python package on the index, and requiring it installs something that is not what the build needs. The native requirement belongs in the build file.

Relying on pyproject.toml to document the requirement. Metadata is read by tools, not by people hitting a build error. The message printed at the moment of failure is what gets read.

Using REQUIRED without a message. CMake’s default failure names the package and stops, which is better than a compiler error and much worse than a message naming the install command. The three extra lines are the whole value.

Shipping an sdist that cannot be built. Omitting CMakeLists.txt or a vendored source directory makes the fallback path fail for a reason unrelated to dependencies. Verify by building from the sdist in CI, not from the working tree.

Frequently Asked Questions

Could a future standard express native dependencies?

Proposals exist for describing external dependencies in metadata, and even a complete standard would describe rather than install them — no Python installer can put a C library on a system. The practical value would be tooling that reads the description and tells a user what to install, which is what the error message does today.

Should the minimum native version match the one I vendor?

Not necessarily. The vendored version is what wheels contain; the minimum in the build file is what a source build requires, and it can be lower if your code only uses older APIs. Keeping them close is simpler, and where they differ, say so in the documentation.

What about build dependencies that are Python packages wrapping native code?

They belong in build-system.requires like any Python package, and it is worth checking that a wheel exists for every platform you support — a build requirement that itself needs compiling makes a source build recursive.

How do I handle a dependency needed only on one platform?

Conditionally in the build file, using the build system’s platform predicates rather than sys.platform, so the condition describes the target rather than the machine running the build. That distinction matters as soon as cross-compilation enters the picture.

Is --no-build-isolation a reasonable workaround for users?

For a user who has already installed the build requirements it works, and it is worth documenting for packagers. It is not a substitute for correct declarations, because it just moves responsibility for the Python build environment onto the person running the command.

Yes, and to a page that stays valid. A URL in a build error is one of the few places a user will reliably follow, which makes it worth pointing at a stable page describing the source-build requirements rather than at a section anchor that may move.

Should the check run for wheel builds as well as source builds?

It runs in both, because it is part of the configure step — and in a wheel build it is harmless, since the environment always has GDAL. Its value is entirely in the source-build path, and it costs a millisecond in the path where it never fires.

What if a user has GDAL but not its headers?

That is the common case on distributions that split development files into a separate package, and the message should name it: libgdal-dev, not gdal. A message that names the runtime package sends the user to install something they already have.