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

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.