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 cluster 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.
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.
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.