cibuildwheel vs manual Docker matrix for GDAL wheels
This page answers one question: should you build your GDAL wheels with cibuildwheel’s managed lifecycle or hand-roll a Docker matrix that drives manylinux containers yourself — and where does each break down for a heavy native stack like GDAL/PROJ/GEOS? It sits inside the Manylinux and Manyarm Docker Base Images cluster of the Modern Python Build Tooling & Wheel Configuration reference, and gives you a decision table, the crossover point, and a hybrid pattern that keeps cibuildwheel’s repair while owning the native build.
Context & Root Cause
Both tools produce the same thing — a repaired manylinux wheel — but they draw the control boundary in different places. cibuildwheel owns the lifecycle: it pulls the manylinux base image, builds the wheel, runs auditwheel repair, and tests in isolation, exposing only hooks (before-all, environment, repair-wheel-command). A manual Docker matrix inverts this: you write the docker run invocations, install GDAL yourself, call the compiler yourself, and invoke auditwheel yourself, gaining total control and paying for every line.
For a pure-Python-plus-thin-C package the choice is easy — cibuildwheel wins outright. GDAL complicates it because the expensive, fiddly part is building the native stack (GDAL depends on PROJ, GEOS, libtiff, libsqlite3, libcurl), and that work lives in a before-all hook regardless of tool. The real question is whether cibuildwheel’s managed image/repair/test layer is worth more than the control a raw matrix gives you over caching, layer reuse, and unusual targets. This page is the decision framework, sitting alongside the image-selection detail in manylinux2014 vs musllinux for spatial libs.
The decision
The two approaches map cleanly onto project maturity and target exotica:
| Concern | cibuildwheel | Manual Docker matrix |
|---|---|---|
| Image selection | Pinned by one config key | You choose and maintain the tag |
| Native GDAL build | In a before-all hook (same work) |
In your Dockerfile (same work) |
| Wheel repair | Automatic auditwheel/delocate/delvewheel |
You script each per platform |
| macOS + Windows | Same config, one runner each | Separate non-Docker jobs entirely |
| Native-build caching | Coarser (hook-level) | Fine-grained Docker layer cache |
| Exotic targets (custom libc, odd arch) | Limited to supported images | Anything you can containerize |
| Maintenance burden | Low | High |
Choose cibuildwheel when your targets are the standard manylinux/musllinux/macOS/Windows grid and you want the matrix, repair, and test isolation for free — which is most projects. Choose a manual matrix only when you need something cibuildwheel cannot express: a bespoke base image, exhaustive Docker-layer caching of a 40-minute GDAL build, or an architecture outside the supported set. The crossover is not project size but target exotica — a large project on standard targets still wants cibuildwheel.
The hybrid that usually wins
You rarely have to pick cleanly. Let cibuildwheel own the lifecycle but hand it a prebuilt GDAL so the slow native compile is cached as a Docker image, not rebuilt every run:
[tool.cibuildwheel]
build = "cp39-*"
manylinux-x86_64-image = "ghcr.io/myorg/manylinux_2_28-gdal:3.8.5" # your image, GDAL baked in
before-all = "echo 'GDAL already in the image'"
# Dockerfile.build — built and pushed once, reused by every wheel run
FROM quay.io/pypa/manylinux_2_28_x86_64
RUN bash /ci/build_gdal_proj_geos.sh # the 40-minute compile, cached as a layer
This keeps cibuildwheel’s repair, test isolation, and multi-platform config while capturing the Docker-layer caching that is the manual matrix’s one decisive advantage. The before-all becomes a no-op because the image already carries GDAL.
Verification
# 1. Confirm the custom image is used and GDAL is present pre-build
docker run --rm ghcr.io/myorg/manylinux_2_28-gdal:3.8.5 gdal-config --version
# expected: 3.8.5
# 2. cibuildwheel still produces a correctly tagged, repaired wheel
pipx run cibuildwheel --platform linux && auditwheel show wheelhouse/*.whl | grep -i tag
# expected: manylinux_2_28_x86_64
A version printed straight from the image and a versioned platform tag confirm the hybrid works: the slow build is cached, the repair still runs. If before-all is still compiling GDAL, the custom image key did not take effect.
Pitfalls & Alternatives
Hand-rolling a matrix to “save time.” Teams underestimate the repair and test-isolation logic cibuildwheel provides. Reimplementing auditwheel invocation, platform tagging, and clean-import testing per platform is weeks of maintenance for parity with a tool you could configure in an afternoon.
Rebuilding GDAL every CI run under cibuildwheel. The default before-all recompiles the native stack on every job — the single biggest waste in a spatial pipeline. Bake GDAL into a custom image (the hybrid) or cache the archives per the async build execution and cache strategies guide.
Choosing manual for macOS/Windows. Docker does not build macOS or Windows wheels; a “manual Docker matrix” still needs separate non-container jobs for those, whereas cibuildwheel spans all three from one config. Do not let the Linux decision dictate the whole matrix.
Related
- Manylinux and manyARM Docker base images — the parent guide on the image lineage both approaches build on.
- Configuring cibuildwheel in pyproject.toml for GDAL — the declarative table the managed path uses.
- Async build execution and cache strategies — how to cache the expensive GDAL compile whichever approach you pick.