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.
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.argsenablesccachefor incremental CI builds and scopesCMAKE_FIND_ROOT_PATHto the pinned/opt/gdal;/opt/projprefixes. TheCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLYsetting 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-tagyields registry-friendly filenames; leavingwheel.py-apiunset produces version-specificcp3X-cp3Xtags, which are correct for GDAL’s NumPy/C++ coupling — a stable-ABIabi3wheel is unsafe here.
Step-by-step
-
Scope CMake discovery in the runner.
scikit-build-coreexecutes 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)andfind_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. -
Build the wheel. With the environment scoped, invoke the build frontend:
python -m build --wheel -
Repair shared-library dependencies.
auditwheel repairvendors external.sofiles into.libs/and rewritesRPATHto$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=ONLYwithout declaringCMAKE_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 theCould NOT find GDALerror even though GDAL is installed. Always pair the mode flag with an explicit prefix list. -
Reaching for a
Py_LIMITED_API/abi3wheel 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-specificcp3X-cp3Xwheels instead. -
Targeting
manylinux2014because it is the older default. GDAL 3.8 and PROJ 9 routinely pull in libraries that exceed the glibc 2.17 floor, soauditwheelrejects the wheel orpipsilently falls back to a source build. Usemanylinux_2_28_x86_64, and confirm the runner image matches the tag you claim.
Related
- Integrating CMake with scikit-build-core — the parent cluster covering backend mechanics, generator selection, and cache hydration.
- Fixing CMake find_package for PROJ — the adjacent fix when PROJ specifically refuses to resolve during configure.
- Mastering pyproject.toml for spatial wheels — the field-by-field reference for the build declaration above.
- Build artifact structuring and packaging — what happens to the repaired wheel before it reaches a registry.