Optimizing scikit-build-core for GDAL

This page answers one narrow question: how do you configure the scikit-build-core backend so a GDAL Python binding compiles deterministically, links against pinned PROJ/GDAL libraries, and survives auditwheel repair without ABI surprises? It sits under the Integrating CMake with scikit-build-core section and assumes you have already replaced any setup.py extension hacks with a PEP 517/518 build. The targets are package maintainers and CI/CD engineers who need an exact error-to-fix mapping and a PyPA-compliant wheel at the end.

Context & Root Cause

GDAL wheel builds fail in CI for a small, recurring set of reasons. CMake cannot locate GDALConfig.cmake because the runner image installs GDAL under a non-standard prefix; scikit-build-core runs find_package(GDAL) inside an isolated build virtualenv that does not inherit your shell’s library paths, so the search collapses to system defaults. When discovery does succeed against the wrong prefix, you instead hit ABI faults — undefined reference to 'GDALOpen' or a PROJ symbol clash — because the build linked one PROJ version while the runtime loads another. Finally, even a clean build is rejected at publish time when auditwheel detects a glibc or shared-library drift that violates the platform tag. All three failures share a single root cause: the build environment is under-specified, so CMake resolves whatever GDAL the host happens to expose rather than the version you pinned. The fix is to make discovery, ABI, and tagging explicit in pyproject.toml and the runner environment.

The scikit-build-core pipeline that turns pinned GDAL/PROJ into a validated manylinux wheel A snaking pipeline. The top row runs left to right: pyproject.toml declares the build-system requires (scikit-build-core, cmake, ninja); scikit-build-core then configures CMake inside an isolated build virtualenv; CMake runs find_package(GDAL/PROJ), which must be scoped by CMAKE_PREFIX_PATH and CMAKE_FIND_ROOT_PATH to the pinned prefixes /opt/gdal and /opt/proj; ninja then compiles and links against those pinned shared objects. The find_package stage is the failure point: when CMAKE_PREFIX_PATH is unset the build aborts with "Could NOT find GDAL". The pipeline turns down and the bottom row runs right to left: a staged wheel under build/{wheel_tag}, then auditwheel repair which vendors the external .so files into .libs and rewrites RPATH to $ORIGIN/.libs, ending in a self-contained, validated manylinux_2_28 wheel. From pinned prefixes to a self-contained wheel — find_package is the single failure point DECLARE pyproject.toml [build-system].requires scikit-build-core · cmake · ninja CONFIGURE scikit-build-core drives CMake isolated build venv DISCOVERY · FAILURE POINT find_package(GDAL/PROJ) CMAKE_FIND_ROOT_PATH → /opt/gdal ; /opt/proj CMAKE_PREFIX_PATH unset → Could NOT find GDAL BUILD ninja compile + link against pinned .so STAGE Staged wheel build/{wheel_tag} link-time libs only REPAIR auditwheel repair vendor .so → .libs/ RPATH = $ORIGIN/.libs VALIDATED manylinux_2_28 wheel self-contained auditwheel show ✓

Failure signatures and deterministic recovery

Error Signature Root Cause Immediate Fix Validation Command
CMake Error: Could NOT find GDAL (missing: GDAL_LIBRARY GDAL_INCLUDE_DIR) CMake cannot resolve GDALConfig.cmake or fall back to gdal-config Export CMAKE_PREFIX_PATH=/opt/gdal or set GDAL_DIR explicitly find /opt/gdal -name "GDALConfig.cmake"
undefined reference to 'GDALOpen' / PROJ: undefined symbol: proj_create Linking against incompatible PROJ/GDAL ABIs across build stages Pin PROJ_VERSION and GDAL_VERSION; rebuild against the exact runtime libraries ldd build/*/gdal.*.so | grep -E "proj|gdal"
scikit_build_core.errors.CMakeNotFoundError: CMake 3.26+ is required Outdated runner image or missing cmake in build-system.requires Add cmake>=3.26 and ninja>=1.11 to [build-system].requires pip show cmake | grep Version
manylinux2014_x86_64 wheel contains incompatible PROJ version auditwheel detects non-compliant shared libraries or glibc drift Bundle PROJ/GDAL .so into the wheel or move to manylinux_2_28_x86_64 auditwheel show dist/*.whl

Solution / Fix

The fix is a deterministic pyproject.toml plus three environment exports that scope CMake discovery. Prerequisites: scikit-build-core>=0.9.0, cmake>=3.26, ninja>=1.11, GDAL 3.8.x and PROJ 9.x installed under fixed prefixes (/opt/gdal, /opt/proj), and CPython 3.9+. For the broader field-by-field rationale, see Mastering pyproject.toml for spatial wheels; this page covers only the GDAL-specific overrides.

[build-system]
requires = ["scikit-build-core>=0.9.0", "cmake>=3.26", "ninja>=1.11"]
build-backend = "scikit_build_core.build"

[project]
name = "gdal-bindings"
version = "3.8.4"
requires-python = ">=3.9"
dependencies = ["numpy>=1.22"]

[tool.scikit-build]
cmake.version = ">=3.26"
cmake.args = [
    "-DCMAKE_BUILD_TYPE=Release",
    "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache",
    "-DGDAL_USE_EXTERNAL_LIBS=ON",
    "-DPROJ_USE_EXTERNAL_LIBS=ON",
    "-DCMAKE_FIND_ROOT_PATH=/opt/gdal;/opt/proj",
    "-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER",
    "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY"
]
wheel.build-tag = "1"
build-dir = "build/{wheel_tag}"
logging.level = "INFO"

The architectural decisions that matter for GDAL:

  • cmake.args enables ccache for incremental CI builds and scopes CMAKE_FIND_ROOT_PATH to the pinned /opt/gdal;/opt/proj prefixes. The CMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY setting only resolves anything because those prefixes are declared — it prevents host-system library bleed.
  • build-dir = "build/{wheel_tag}" stages artifacts per Python ABI, preventing cross-ABI cache corruption — the same isolation discipline described in Async build execution and cache strategies.
  • wheel.build-tag yields registry-friendly filenames; leaving wheel.py-api unset produces version-specific cp3X-cp3X tags, which are correct for GDAL’s NumPy/C++ coupling — a stable-ABI abi3 wheel is unsafe here.

Step-by-step

  1. Scope CMake discovery in the runner. scikit-build-core executes CMake in an isolated virtualenv, so export the search paths before invoking the build:

    export CMAKE_PREFIX_PATH="/opt/gdal:/opt/proj"
    export PKG_CONFIG_PATH="/opt/proj/lib/pkgconfig:/opt/gdal/lib/pkgconfig"
    export CMAKE_LIBRARY_PATH="/opt/gdal/lib:/opt/proj/lib"
    

    These force find_package(GDAL) and find_package(PROJ) to bind the pinned prefixes instead of system defaults. PROJ-specific discovery edge cases are covered in Fixing CMake find_package for PROJ.

  2. Build the wheel. With the environment scoped, invoke the build frontend:

    python -m build --wheel
    
  3. Repair shared-library dependencies. auditwheel repair vendors external .so files into .libs/ and rewrites RPATH to $ORIGIN/.libs:

    auditwheel repair dist/gdal_bindings-*.whl \
        --plat manylinux_2_28_x86_64 --wheel-dir wheelhouse/
    

    This is the same vendoring trade-off analysed in Vendoring PROJ and GDAL vs system libraries; the repaired wheel runs without a system GDAL install. Run the whole pipeline inside the manylinux_2_28 Docker base images that anchor the glibc baseline, and lock the toolchain with a reproducible pixi environment so cmake, ninja, and the compiler never drift between runners.

Verification

Run these gates against the repaired wheel; the expected output is shown so you can diff against a failing build.

auditwheel show wheelhouse/gdal_bindings-*.whl

Expect a line confirming the tag was lowered to the platform you targeted, e.g. gdal_bindings-3.8.4-cp39-cp39-manylinux_2_28_x86_64.whl is consistent with the following platform tag: "manylinux_2_28_x86_64".

Confirm the GDAL/PROJ symbols resolve from the bundled libraries rather than the host. With nm -D, GDAL/PROJ symbols normally appear as U (undefined) — they are resolved at load time from the linked libgdal/libproj via the ELF NEEDED entries, which is expected. They appear as T only if statically linked:

nm -D wheelhouse/extracted/gdal.*.so | grep -E "GDALOpen|proj_create" | head -n 5

Then prove the wheel is self-contained with a clean-venv import smoke test:

python -m venv /tmp/v && /tmp/v/bin/pip install --no-deps wheelhouse/gdal_bindings-*.whl
/tmp/v/bin/python -c "from osgeo import gdal; print(gdal.__version__)"

Expect the import to succeed and print 3.8.4 with no libgdal installed on the host. A genuinely missing dependency shows up in ldd wheelhouse/extracted/gdal*.so as not found.

Where a GDAL Wheel Build Spends Its Minutes

Optimising a build productively starts with knowing which phase dominates, and for a GDAL-linked extension the answer is rarely the phase people tune first.

Time spent per phase in a scikit-build-core wheel build linking GDAL Five phases with their durations. Creating the isolated build environment takes about twenty seconds. CMake configuration, dominated by find_package probes, takes about forty seconds. Compiling the extension's own sources takes about twenty-five seconds. Linking takes about ten seconds. Repairing and tagging the wheel takes about fifteen seconds. A separate bar shows that when GDAL itself is compiled rather than provided, that single step adds twelve minutes and dwarfs everything else. with GDAL already installed in the environment isolated env 20 s cmake configure 40 s — mostly find_package probes compile sources 25 s link 10 s repair + tag 15 s when GDAL is compiled in the same job compile GDAL/PROJ/GEOS — about 12 minutes tuning the compile flags of your own sources changes 25 seconds; moving the native build out of the per-commit path changes 12 minutes

Two consequences follow. If GDAL is compiled in the same job, nothing else on the chart matters and the only worthwhile optimisation is to stop doing that on every commit — a prebuilt image or a cached prefix. If GDAL is already present, the configure phase is the largest single item, and it is dominated by find_package probes rather than by anything you wrote.

Cutting the Configure Phase

Configure time is easy to ignore because no single probe is slow. Three changes remove most of it without affecting what the build produces.

Three ways to shorten the CMake configure phase in a spatial wheel build Three techniques. Using Ninja instead of the default generator removes per-target overhead and parallelises the probes. Requesting config mode explicitly avoids a fallback module search that scans many prefixes. Restricting the search prefixes to the pinned environment avoids probing system directories that will never provide the library. A closing note observes that the build directory is fresh on every wheel build, so configure cost is paid every time and is not amortised. use the Ninja generator cmake.args = ["-GNinja"] parallelises probes and removes per-target overhead the default generator pays request config mode explicitly find_package(PROJ 9.3 CONFIG REQUIRED) skips the module-mode fallback, which scans many prefixes before giving up restrict the search prefixes CMAKE_PREFIX_PATH = the pinned prefix only stops probes walking system directories that will never supply the library worth doing because a wheel build configures in a fresh directory every time — unlike a development build, the cost is never amortised

The last line is the reason this matters more for wheel builds than for ordinary CMake projects: build isolation gives every wheel build a clean directory, so the configure phase runs in full on every single build in the matrix rather than once per developer machine.

Pitfalls & Alternatives

  • Setting CMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY without declaring CMAKE_FIND_ROOT_PATH. This is the most common self-inflicted failure: the mode tells CMake to only look inside the root path, but with no root path declared the library search resolves nothing and you get the Could NOT find GDAL error even though GDAL is installed. Always pair the mode flag with an explicit prefix list.

  • Reaching for a Py_LIMITED_API / abi3 wheel to shrink the build matrix. GDAL bindings link against NumPy’s C API and a C++ runtime whose symbols are version-coupled, so a stable-ABI wheel silently mismatches across CPython minor versions — the exact hazard described in C-API vs CPython ABI compatibility. Build version-specific cp3X-cp3X wheels instead.

  • Targeting manylinux2014 because it is the older default. GDAL 3.8 and PROJ 9 routinely pull in libraries that exceed the glibc 2.17 floor, so auditwheel rejects the wheel or pip silently falls back to a source build. Use manylinux_2_28_x86_64, and confirm the runner image matches the tag you claim.

Frequently Asked Questions

Does any of this change what ends up in the wheel?

No, which is what makes these changes safe to adopt. Generator choice, discovery mode and prefix restriction affect how quickly the configure step reaches its answer, not what that answer is. If a wheel behaves differently after applying them, something in the configuration was ambiguous before — usually a library that was being found in two places.