Mastering pyproject.toml for Spatial Wheels
The pyproject.toml manifest is the build contract for every geospatial Python package, and getting it wrong is why so many GDAL, PROJ, and Shapely wheels compile cleanly in CI yet fail to import on a user’s machine. This guide sits inside Modern Python Build Tooling & Wheel Configuration, the parent reference for compiling and distributing spatial Python packages, and focuses narrowly on the declarative manifest that drives backend selection, ABI-aware dependency bounds, package discovery, and C-extension routing. It targets build 1.2.x, setuptools 69+, Cython 3.0.x, NumPy 2.x with a NumPy 1.x ABI floor, scikit-build-core 0.9.x, and meson-python 0.16.x, and shows how to author a manifest that produces relocatable wheels for shapely, pyproj, rasterio, and fiona rather than a metadata file that merely satisfies pip.
Prerequisites & Environment
The manifest is parsed before any compiler runs, so the toolchain that reads it must be pinned just as tightly as the libraries it links against. A pyproject.toml that builds against setuptools 80 and NumPy 2.1 on a developer laptop but setuptools 69 and NumPy 1.26 on a runner produces two binaries with different ABIs from one source tree. Pin every participant before authoring a single table, and source the geospatial system libraries from the locked toolchain described in environment isolation with Pixi and Conda so the headers the build sees are the headers the wheel claims.
| Component | Pinned version | Why it matters for the manifest |
|---|---|---|
build |
1.2.2 |
PEP 517 front end; creates the isolated env from [build-system].requires |
pip |
24.2 |
Resolver that enforces requires-python and dependencies at install time |
setuptools |
69.5.1 |
Default backend; reads [tool.setuptools] package and data maps |
wheel |
0.43.0 |
Produces and tags the .whl; consulted for py_limited_api emission |
Cython |
3.0.10 |
Build-time only; must be in requires, never in dependencies |
| NumPy (build) | >=2.0 headers, 1.x ABI |
Compile against the oldest ABI you support to stay forward-compatible |
scikit-build-core |
0.9.8 |
CMake-backed backend for C++17 spatial extensions |
meson-python |
0.16.0 |
Alternative backend with pure-TOML extension declaration |
| Build image | quay.io/pypa/manylinux_2_28_x86_64 |
glibc floor baked into the platform tag the wheel claims |
Set the reproducibility floor in the build environment so the manifest produces a bit-stable wheel on every rerun. These belong in the CI job environment, not in pyproject.toml:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export PYTHONHASHSEED=0
export PIP_NO_BUILD_ISOLATION=0 # keep isolation ON; only disable deliberately
export PIP_CONSTRAINT=constraints/build.txt
For stacks that pin system GDAL/PROJ through Conda, lock the build environment first — the manifest’s [build-system].requires covers only Python build dependencies, so libgdal.so and proj.db must already exist in the prefix before build is invoked. The lock-file workflow that guarantees this is covered in configuring Pixi environments for wheel building.
Core Configuration
A spatial manifest has three load-bearing tables: [build-system] decides who compiles, [project] declares what the wheel needs at runtime, and the backend-specific [tool.*] table maps where the compiled outputs land. Treat each as a separate contract.
The [build-system] table dictates how pip and build invoke the compiler toolchain in an isolated virtual environment. The requires array must be exhaustive — omitting Cython or NumPy here triggers silent fallbacks, missing-header errors, or an ABI mismatch when compiling against spatial C-APIs:
[build-system]
requires = [
"setuptools>=69.0",
"wheel>=0.43",
"Cython>=3.0,<3.1",
"numpy>=2.0.0", # build against 2.x headers; the ABI floor is set in code
"packaging>=23.1",
]
build-backend = "setuptools.build_meta"
The [project] table declares runtime dependencies with strict lower bounds. Upper bounds belong only where a documented ABI break exists — the NumPy 2.0 transition or a GEOS 3.12 C-API change — never as defensive guesswork that strands the wheel against future releases:
[project]
name = "geo-core"
version = "2.4.1"
requires-python = ">=3.9"
dependencies = [
"shapely>=2.0.0",
"pyproj>=3.4.0",
"rasterio>=1.3.0",
"numpy>=1.24", # runtime floor; matches the compiled ABI, not the build headers
]
[project.optional-dependencies]
test = ["pytest>=8.0", "hypothesis>=6.100"]
When a wheel is installed, pip validates requires-python and resolves dependencies against the target environment. For spatial stacks this narrow window is critical: extensions link against system-level libgdal.so or libproj.so, and a mismatched runtime version causes segmentation faults or — more insidiously — silent coordinate-transformation errors. The reason the runtime NumPy floor (>=1.24) can be lower than the build header (>=2.0) is the forward-compatibility guarantee of the NumPy ABI, which is part of the broader C-API versus CPython ABI compatibility contract.
The third table maps packages and compiled outputs. setuptools cannot declare extension objects in pyproject.toml, so its TOML config covers discovery and data only:
[tool.setuptools.packages.find]
where = ["src"]
exclude = ["tests*", "benchmarks*"]
[tool.setuptools.package-data]
"geo_core" = ["py.typed"]
"geo_core.data" = ["*.json", "proj.db"] # data files survive the build but not ABI repair
That last line is the most common silent failure: proj.db and GDAL’s gdal_data/ are not shared objects, so the repair tools ignore them entirely. If they are not declared as package data, the wheel imports cleanly and then raises at the first reprojection. The downstream staging that depends on this layout is detailed in build artifact structuring and packaging.
Step-by-Step Implementation
Author the manifest in the order the build consumes it: backend, metadata, package map, extension, then build and repair. Each step is a concrete edit or a runnable command.
-
Pin the build backend in an isolated environment. Write the
[build-system]table above, then confirm the front end can construct the isolated env without falling back to a legacysetup.py egg_info:python -m build --sdist --no-isolation=false 2>&1 | grep -i "Installing build dependencies" -
Declare ABI-aware metadata. Fill in
[project]with the lower-boundeddependenciesand an explicitrequires-python. Validate the table against the PEP 621 schema before building:pipx run validate-pyproject pyproject.toml # Validating pyproject.toml ... valid -
Map packages and exclude test fixtures. Use
[tool.setuptools.packages.find]with asrc/layout so benchmark data and tests never leak into the wheel, and declare every non-.sodata file explicitly. -
Declare the C extension in a
setup.pyshim.setuptoolshas no TOML surface forExtensionobjects, so a minimalsetup.pysits beside the manifest and carries only the compile and link directives:# setup.py — extension metadata only; everything else lives in pyproject.toml from setuptools import setup, Extension ext = Extension( "geo_core._gdal", sources=["src/geo_core/gdal_wrap.c"], define_macros=[ ("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION"), # pin the NumPy ABI floor ("Py_LIMITED_API", "0x03090000"), # opt into the stable ABI ], extra_compile_args=["-O2", "-fPIC", "-fvisibility=hidden"], extra_link_args=["-Wl,-rpath,$ORIGIN/lib"], py_limited_api=True, ) setup(ext_modules=[ext]) -
Or switch to a pure-TOML backend for C++ extensions. Projects that want extension configuration without a
setup.pyshould adopt the scikit-build-core backend that translates the manifest into CMake invocations, ormeson-python. The[build-system]table changes accordingly:[build-system] requires = ["scikit-build-core>=0.9", "numpy>=2.0"] build-backend = "scikit_build_core.build" [tool.scikit-build] cmake.version = ">=3.26" wheel.py-api = "cp39" # emit an abi3 / Py_LIMITED_API wheel -
Build the raw wheel into a quarantine directory. Keep the unrepaired wheel away from the publishable output so a half-finished artifact can never be uploaded:
python -m build --wheel --outdir dist/raw/ -
Repair to embed the platform tag. The
auditwheel repairstep bundles the linked GDAL/PROJ shared objects and rewritesRPATHto$ORIGIN/.libs, promoting the barelinux_x86_64tag to themanylinux_2_28tag the manifest’s image floor promises:auditwheel repair dist/raw/*.whl \ --plat manylinux_2_28_x86_64 \ --wheel-dir dist/
Verification
Confirm the manifest produced what it claimed before trusting a green check. Each command targets a different contract: schema validity, ABI floor, bundled libraries, and a clean import.
Validate the manifest itself and inspect the metadata build generated from it:
python -m build --wheel --outdir dist/raw/
unzip -p dist/raw/geo_core-2.4.1-*.whl '*/METADATA' | grep -E '^(Requires-Python|Requires-Dist)'
# Requires-Python: >=3.9
# Requires-Dist: shapely>=2.0.0
# Requires-Dist: pyproj>=3.4.0
Confirm the stable-ABI opt-in actually produced an abi3 tag rather than a version-locked one — a cp312 tag here means py_limited_api did not take:
ls dist/*.whl
# geo_core-2.4.1-cp39-abi3-manylinux_2_28_x86_64.whl
auditwheel show dist/*.whl
# ... is consistent with the following platform tag: "manylinux_2_28_x86_64".
# The following external shared libraries are required: libgdal.so.34, libproj.so.25, ...
Prove the wheel installs and imports in an isolated environment, resolving its shared libraries against the bundled copies rather than the host — the RPATH mechanics behind this are covered in shared library path resolution:
python -m venv /tmp/verify && /tmp/verify/bin/pip install --no-deps dist/*.whl
/tmp/verify/bin/python -c "import geo_core; print(geo_core.__version__)"
# 2.4.1
Which Table Owns Which Decision
pyproject.toml collects settings for several different consumers into one file, and a setting placed in the wrong table is ignored rather than rejected. For a spatial package there are four consumers, and knowing which owns what removes most of the guesswork.
The last row is the one that produces the most confusion. Everything under [tool.cibuildwheel] describes how your CI builds wheels; a user running pip install from a source distribution never sees it. If a build option is required for the package to compile at all, it belongs in the scikit-build table or in CMakeLists.txt, not in the cibuildwheel table — otherwise the wheels work and every source build fails.
Declaring the Floor in Two Places
An abi3 spatial wheel states its minimum interpreter twice, and the two statements have to agree or the artifact is subtly wrong in a way no build step checks.
A one-line check in the release job closes the gap: parse the interpreter tag out of the wheel filename, compare it with the Requires-Python field in the metadata, and fail if they disagree. It takes seconds and prevents a whole category of installation reports that are otherwise very hard to diagnose from the outside.
Optimization & Edge Cases
Once the manifest builds clean, the remaining decisions are about backend choice, ABI floors, and what the resolver is allowed to do.
- Compile against the oldest NumPy ABI you support. With NumPy 2.x you build against the 2.x headers but keep
NPY_NO_DEPRECATED_APIpinned toNPY_1_7_API_VERSION, which lets a single binary import under both NumPy 1.x and 2.x runtimes. Drop thedefine_macrosguard and the wheel hard-pins to whatever NumPy was present at build time. abi3is rare but worth it where it fits. TargetingPy_LIMITED_API=0x03090000collapses a per-interpreter matrix into one wheel that imports on 3.9+, but only if no symbol outside the limited set is touched. Heavy NumPy C-API use often disqualifies a spatial extension; weigh it against the version-pinning matrix in C-API versus CPython ABI compatibility.- Choose the backend by the extension’s language surface. Pure-C extensions with a thin wrapper stay on
setuptoolsplus asetup.pyshim; C++17 spatial codebases that already ship aCMakeLists.txtbelong onscikit-build-core, tuned as in optimizing scikit-build-core for GDAL.meson-pythonis the third option when you want pure-TOML extension declaration without CMake. - Editable installs need PEP 660 support.
pip install -e .against a C-extension only works if the backend implementsbuild_editable;setuptools64+ andscikit-build-coreboth do, but the rebuild-on-import behaviour differs and can mask a stale.so. - musl and glibc need separate builds, not separate tags. The same manifest produces a
musllinux_1_2wheel only when built inside a musl image with musl-linked GDAL; you cannot re-tag a glibc binary. The trade-off is laid out in manylinux2014 vs musllinux for spatial libs. - Cache the resolver, not the build tree. Key
~/.cache/pipand a CMake build directory per platform tag; the full invalidation strategy lives in async build execution and cache strategies.
Troubleshooting
ModuleNotFoundError: No module named 'numpy' (raised during build, not at runtime)
The build backend tried to import numpy to locate headers, but NumPy was missing from [build-system].requires. Build isolation creates a clean environment that contains only what requires lists — add numpy>=2.0 there. The same applies to Cython; a missing entry surfaces as a .pyx-cannot-be-compiled error instead.
ImportError: numpy.core.multiarray failed to import
The extension was compiled against a newer NumPy ABI than the runtime provides. This is the classic effect of dropping the NPY_NO_DEPRECATED_API floor or letting build isolation pull a different NumPy than the runtime resolver. Rebuild against the oldest supported NumPy headers and confirm the runtime dependencies floor matches.
error: Multiple top-level packages discovered in a flat-layout: ['geo_core', 'tests', 'benchmarks']
setuptools auto-discovery found more than one importable directory and refuses to guess. Either move the package under src/ and set [tool.setuptools.packages.find] where = ["src"], or list packages explicitly — never ship tests inside the wheel.
BackendUnavailable: Cannot import 'setuptools.build_meta'
The build-backend string is wrong or setuptools is absent from requires. Confirm build-backend = "setuptools.build_meta" exactly and that setuptools>=69 appears in [build-system].requires; a typo here makes pip fall back to a legacy path that ignores the manifest entirely.
RuntimeError: Unsupported compiler -- at least C++17 support is needed (from scikit-build-core)
The CMake toolchain selected a compiler older than the spatial extension requires. Set the floor in CMake and rebuild inside the manylinux_2_28 Docker base images whose GCC satisfies C++17; a related find_package(PROJ) failure is resolved in fixing CMake find_package for PROJ.
Frequently Asked Questions
Why does a setting I added have no effect?
Almost always because it is in the wrong table. Nothing validates that a key belongs to the consumer you intended, so a misplaced setting is silently ignored rather than rejected. Check which of the four consumers owns the behaviour you are trying to change — the environment creator, the metadata generator, the build backend or the wheel builder — and move the key there.
Should requires-python match the abi3 floor exactly?
Yes. A lower value lets a resolver install the wheel on an interpreter it was not built for, producing an import error the user cannot interpret; a higher value excludes people the wheel would have served. Assert the match in CI by comparing the interpreter tag in the wheel filename against the metadata field, because nothing in the toolchain checks it for you.
Do build requirements belong in build-system.requires or in the environment?
In build-system.requires when they are Python packages the backend needs — the backend itself, a versioning plugin, a repair tool used from the backend. Native dependencies such as GDAL and its headers cannot be expressed there at all and must come from the environment or the container, which is the fundamental reason a spatial build needs more than a manifest.
How should version numbers be managed?
From a single source, either a static value in the manifest or a plugin that derives it from version-control tags. What matters for a compiled package is that the value is available at build time without importing the package — importing a package that has not been built yet is a bootstrapping failure that appears the first time someone builds from a clean checkout.
Is it worth documenting the vendored library versions in the manifest?
It is worth recording them somewhere machine-readable, and the manifest is a reasonable home if they are also read by the build. What matters more is that they end up in the wheel — in the metadata or in a small module — so a user can query what they actually have without unpacking anything.
Related
- Configuring cibuildwheel in pyproject.toml for GDAL — the
[tool.cibuildwheel]table that drives the matrix build, repair, and test from this same manifest. - Modern Python Build Tooling & Wheel Configuration — the parent reference tying backends, base images, and registry publishing together.
- Integrating CMake with scikit-build-core — the CMake-backed backend to adopt when a
setup.pyshim is no longer enough for C++ spatial extensions. - Build artifact structuring and packaging — what happens to the wheel this manifest produces, from staging to
twine check. - Environment isolation with Pixi and Conda — locking the system GDAL/PROJ that
[build-system].requiresdeliberately does not cover. - C-API vs CPython ABI compatibility — the ABI rules behind
Py_LIMITED_API, the NumPy floor, and the dependency bounds declared here.
Further reading: PEP 517 and PEP 621 for the authoritative build-backend and project-metadata specifications.