Configuring cibuildwheel in pyproject.toml for GDAL
This page answers one question: what exactly goes in the [tool.cibuildwheel] table of pyproject.toml so that a GDAL-linked extension builds, repairs, and tests across the Linux/macOS/Windows matrix from a single declarative config — including the before-all GDAL install, the repair commands, and the abi3 build selection? It sits inside the Mastering pyproject.toml for Spatial Wheels section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you a complete, annotated table plus the per-platform overrides GDAL forces.
Context & Root Cause
cibuildwheel runs a fixed lifecycle for every wheel in the matrix — before-all, build, repair, test — and reads its behaviour from configuration. Putting that configuration in pyproject.toml (rather than environment variables scattered across a CI YAML) makes the build reproducible locally with pipx run cibuildwheel and keeps the matrix logic in one reviewed file. The reason GDAL needs more than the defaults is that cibuildwheel’s build containers are minimal manylinux/musllinux images with no geospatial libraries: unless before-all installs GDAL and PROJ into the container, the compile step fails at find_package(GDAL).
The second GDAL-specific wrinkle is the repair and test stages. The default repair works, but the datum database and the platform image often need overriding, and the test stage must run in an isolated environment so it exercises the bundled GDAL, not a system one. Getting these into the declarative table — rather than patching them per-runner — is what this page provides. It builds on the manifest fundamentals in Mastering pyproject.toml for Spatial Wheels.
Solution / Fix
This targets cibuildwheel 3.0+, GDAL 3.8.x/PROJ 9.3.x, and an abi3 extension floored at Python 3.9.
1. The core table
[tool.cibuildwheel]
# One abi3 wheel per platform covers every supported interpreter.
build = "cp39-*"
build-frontend = "build"
# Pin the images that fix the glibc/musl floor for spatial libs.
manylinux-x86_64-image = "manylinux_2_28"
musllinux-x86_64-image = "musllinux_1_2"
# Install the native geospatial stack INTO the build container.
before-all = "bash ci/install_gdal.sh"
environment = { CFLAGS = "-fPIC -O2 -fvisibility=hidden -DPy_LIMITED_API=0x03090000", GDAL_CONFIG = "/opt/gdal/bin/gdal-config" }
# Prove the bundled GDAL imports in a clean venv, not the build env.
test-command = "python -c \"from osgeo import gdal; print(gdal.__version__)\""
The manylinux-x86_64-image pin is not cosmetic — it selects the manylinux_2_28 Docker base image whose glibc floor every Linux wheel inherits.
2. Per-platform overrides GDAL forces
[tool.cibuildwheel.linux]
before-all = "yum install -y sqlite-devel libtiff-devel && bash ci/install_gdal.sh"
repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel}"
[tool.cibuildwheel.macos]
before-all = "brew install gdal proj"
repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}"
[tool.cibuildwheel.windows]
before-all = "ci\\install_gdal.bat"
repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}"
Windows needs delvewheel because it has no RPATH, the divergence explained in fixing “DLL load failed” for GDAL on Windows.
3. Run it — identically local and in CI
pipx run cibuildwheel --platform linux # reproduces the CI build on your laptop
Verification
# 1. Confirm cibuildwheel resolves the config and lists the expected builds
pipx run cibuildwheel --print-build-identifiers --platform linux
# expected: cp39-manylinux_x86_64, cp39-musllinux_x86_64, ... (one abi3 line per target)
# 2. After a run, every wheel carries a versioned platform tag
ls wheelhouse/ && auditwheel show wheelhouse/*manylinux*.whl | grep -i tag
# expected: manylinux_2_28_x86_64
# 3. The built-in test stage must have passed — re-run standalone
docker run --rm -v "$PWD/wheelhouse:/w" python:3.12-slim \
bash -c "pip install /w/*manylinux*.whl && python -c 'from osgeo import gdal; print(gdal.__version__)'"
# expected: 3.8.x
A single cp39 identifier per target, a manylinux_2_28 tag, and a clean import confirm the table is correct. If --print-build-identifiers lists cp310, cp311, etc., the build = "cp39-*" abi3 selection is not being applied.
What cibuildwheel Does Per Cell
The configuration is easier to reason about once you know the sequence it drives inside each matrix cell, because every setting slots into exactly one step of that sequence.
Step three is where a spatial build spends its time and where most of the configuration complexity lives, because it is the step that turns a bare manylinux container into one that has GDAL. It is also the step to cache: everything after it is fast, and everything before it is the container.
The Environment Is the Only Channel
cibuildwheel runs the build inside a container, and the container does not inherit your shell. Anything the build needs must arrive through the environment table or through a command that runs inside the container — a constraint that explains a large share of “it works locally” reports.
The practical rule is that the container must be able to build the wheel starting from the project directory alone. Anything else it needs is either installed by before-all or passed by name through environment, and a build that satisfies that rule is one a new contributor can reproduce on their laptop.
Pitfalls & Alternatives
Omitting before-all. The manylinux container has no GDAL; without installing it, the build dies at configure. Always install the native stack in before-all, or vendor it via a pixi environment.
Testing in the build environment. If test-command runs where GDAL is already installed, a broken bundle passes anyway. cibuildwheel isolates the test venv by default — do not defeat it by adding the build env to the test path.
Building one wheel per interpreter. Dropping the cp39-* abi3 selection multiplies the matrix by every Python version and inflates build time. Keep the Stable-ABI build unless a dependency forbids it. For whether to run this at all versus a hand-rolled Docker matrix, see cibuildwheel vs manual Docker matrix for GDAL wheels.
Frequently Asked Questions
Should build select one identifier or several?
One, if you are building abi3 wheels — build = "cp39-*" produces a single wheel per platform that serves every later interpreter. Selecting several identifiers multiplies the matrix by the number of interpreters and produces artifacts that are, for a geospatial binding, functionally identical. The exception is a package that genuinely uses version-specific C-API features, which is rare in this domain.
Where should the native dependencies be installed?
In before-all, which runs once per platform inside the build container, rather than in before-build, which runs per wheel. For a spatial package that distinction is the difference between compiling GDAL once and compiling it once per interpreter. Better still, install a prebuilt GDAL from a pinned image or a cached prefix so before-all is a copy rather than a compile.
Why do my environment variables not reach CMake?
Because the build runs inside a container that does not inherit your shell. Only the project directory, the variables listed in the environment table and anything before-all created inside the container are visible. A configure step referring to a path that exists only on the runner fails with a missing-file error that looks unrelated to the configuration.
When should repair-wheel-command be overridden?
Rarely, and always for a specific reason you can state — most commonly to pass --add-path to the Windows repair tool so it can find DLLs outside the wheel tree, or to add --require-archs on macOS so a missing architecture slice fails the build. Overriding it to skip repair entirely produces a wheel that will not install correctly anywhere, and is worth blocking in review.
What does test-skip cost me?
Whatever the skipped cell would have proved. Skipping tests for cross-built wheels is legitimate, because the artifact cannot execute on the build machine, but it leaves a gap that has to be filled elsewhere — an emulated import job, or a native runner in a later stage. A test-skip with no compensating check means the first machine to run that wheel belongs to a user.
How do I keep the configuration readable as the matrix grows?
Put per-platform overrides in their own tables rather than piling conditionals into one, and keep anything that is really build logic in a script the configuration calls. The goal is that the table describes which platforms and the script describes how to build, so adding a platform is a list entry rather than an edit to a command line.
Can the configuration live in a separate file instead?
It can, and keeping it in the manifest is usually better for a spatial project. One file that a contributor can read end to end — metadata, backend settings and the wheel matrix — beats three that have to be reconciled mentally. Split it out only when the matrix grows large enough that it obscures everything else. Keeping the table small also keeps it reviewable, which matters because it is the file that decides what every published artifact contains.
Related
- Mastering pyproject.toml for spatial wheels — the parent guide on
[build-system], metadata, and the ABI flags this table reuses. - cibuildwheel vs manual Docker matrix for GDAL wheels — when the declarative tool is the right call versus a bespoke matrix.
- manylinux and manyARM Docker base images — the images the
manylinux-x86_64-imagekey selects.