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.

Where cibuildwheel and a manual Docker matrix differ across the build lifecycle Across five lifecycle concerns — image selection, native dependency build, wheel repair, cross-platform coverage, and maintenance — cibuildwheel manages image, repair, and platform coverage automatically while leaving the native build to before-all hooks, whereas a manual Docker matrix gives full control of every stage at the cost of writing and maintaining each one. cibuildwheel manual Docker matrix image native build repair multi-platform maintenance managed before-all hook automatic one config low you pick full control you script per-arch job high

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.