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.

The cibuildwheel lifecycle stages driven from pyproject.toml A single pyproject.toml table drives four ordered cibuildwheel stages: before-all installs GDAL and PROJ into the build container, build compiles the abi3 extension, repair runs auditwheel or delocate or delvewheel per platform, and test imports the wheel in isolation. Each stage reads its settings from the same tool.cibuildwheel table. [tool.cibuildwheel] one declarative table before-all install GDAL/PROJ build cp39-* abi3 repair auditwheel/delocate test import in isolation

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.

The steps cibuildwheel runs inside one matrix cell, and the setting that controls each Six steps. Selecting build identifiers is controlled by the build and skip settings. Preparing the container is controlled by the manylinux image settings. Running before-all commands installs native dependencies. Building the wheel is driven by the backend and the environment settings. Repairing the wheel is controlled by the repair-wheel-command. Testing the wheel is controlled by test-command and test-skip. Each step names the setting that governs it. 1 · select identifiers build · skip one abi3 identifier per platform when build = "cp39-*" 2 · prepare the container manylinux-*-image pin by digest so the toolchain cannot move under the cache 3 · before-all before-all where GDAL, PROJ and GEOS are built or installed — the slow step 4 · build the wheel environment · config-settings the backend runs here; CMake sees only what environment passes 5 · repair repair-wheel-command auditwheel, delocate or delvewheel — override only when you must 6 · test test-command · test-skip — skip only for cross-built cells

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.

What crosses the boundary into the cibuildwheel build container Outside the container are the CI runner's environment variables, an activated conda prefix, and files outside the project directory. Crossing the boundary are the project directory itself, variables listed in the environment table, and anything installed by a before-all command. Blocked at the boundary are shell exports not listed in the table, paths that exist only on the runner, and an activated environment. A note records that a build referring to a runner path fails inside the container with a missing-file error. on the runner exported shell variables an activated conda prefix files outside the project dir tools installed on the runner none of this is visible inside the boundary crosses it the project directory, mounted variables named in [tool.cibuildwheel] environment whatever before-all installs inside the container CIBW_* variables set in the CI job a configure step referring to /opt/gdal on the runner fails inside the container with "No such file or directory" — the fix is a before-all command that installs GDAL inside, never a path that only the host can see

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.