How to fix ABI version mismatch in GDAL wheels
A GDAL wheel that installs cleanly but raises undefined symbol, cannot open shared object file, or Module compiled against GDAL X.Y but runtime is X.Z at import time is almost always a collision between the compiled C-extension’s ABI tag and the native libgdal.so it resolves at runtime — this page shows how to diagnose the exact signature and fix it. It sits under the C-API vs CPython ABI Compatibility cluster, part of the broader Geospatial C-Extension Fundamentals & ABI Architecture reference.
Start from the error signature and follow the branch to its fix:
Context & Root Cause
A geospatial extension wheel encodes two independent ABI layers, and pip resolves only one of them. The first is the interpreter ABI — the cp310-cp310 or abi3 tag that decides whether a .cpython-310-x86_64-linux-gnu.so binary can load under your Python. The interpreter-side contract is covered in depth in the C-API vs CPython ABI Compatibility cluster.
The second layer is the native GDAL C-API. libgdal exports versioned symbols such as GDALOpenEx@GDAL_3.5 and guarantees backward compatibility only within a major release. When pip selects a wheel it matches the CPython tag and ignores the native ABI entirely. If the host has a system gdal package, LD_LIBRARY_PATH or /etc/ld.so.conf frequently forces the dynamic linker to bypass the wheel’s vendored .libs/ directory and load the older system libgdal.so, producing the symbol-resolution failure. The mismatch is therefore a linkage problem, not a Python problem.
Solution / Fix
1. Triage the exact signature first
Before touching any build config, identify which layer broke. Run these immediately after a failed pip install or import (GDAL 3.6+, auditwheel ≥5.4):
# Verify the CPython interpreter / platform tag
python -c "import sysconfig; print(sysconfig.get_platform())"
# Inspect which libgdal the compiled extension actually links against
ldd "$(python -c 'import osgeo._gdal as g; print(g.__file__)')" 2>/dev/null | grep -iE "gdal|proj"
# Confirm auditwheel's injected RPATH and platform tag
auditwheel show dist/*.whl
Map the signature to its fix:
| Error signature | Root cause | Immediate fix |
|---|---|---|
ImportError: libgdal.so.32: cannot open shared object file |
Vendored libgdal.so not on the loader path; RPATH missing or shadowed by LD_LIBRARY_PATH. |
Re-run auditwheel repair so $ORIGIN/.libs is injected. |
ImportError: /usr/lib/libgdal.so.30: undefined symbol: GDALOpenEx |
C-API drift: wheel compiled against GDAL ≥3.4, runtime resolved an older system libgdal. |
Pin build-time GDAL to the deployment runtime, or force vendoring. |
ValueError: Module compiled against GDAL 3.6 but runtime is 3.8 |
osgeo ABI guard tripped by major.minor drift. |
Align GDAL_VERSION across build and deploy, then rebuild. |
ImportError: /lib/x86_64-linux-gnu/libm.so.6: version 'GLIBC_2.32' not found |
manylinux platform tag newer than the host glibc. |
Rebuild on an older manylinux base image. |
2. Build in an isolated, GDAL-free container
Never compile spatial wheels on a host that already has system GDAL — the build will silently link against it. Build inside a pinned manylinux Docker base image instead:
FROM quay.io/pypa/manylinux_2_28_x86_64:latest
ENV GDAL_VERSION=3.8.4
RUN yum install -y proj-devel sqlite-devel curl-devel zlib-devel
RUN pip install --no-cache-dir --upgrade pip setuptools wheel "auditwheel>=5.4"
3. Force the wheel to vendor its own libgdal
Override system resolution by pinning RPATH to $ORIGIN/.libs at link time. Whether you vendor or rely on a system library is a deliberate decision — see vendoring PROJ and GDAL vs system libraries — and for reproducible builds it should be the vendored path:
export LDFLAGS="-Wl,-rpath,\$ORIGIN/.libs"
pip wheel --no-build-isolation -w dist/ .
If you drive the native build through the scikit-build-core backend, pin the version at the CMake level with find_package(GDAL 3.8.4 EXACT) so a drifting system package cannot satisfy the build silently; the GDAL-specific tuning lives in optimizing scikit-build-core for GDAL.
4. Repair with explicit platform targeting
auditwheel repair rewrites the ELF RPATH to point at $ORIGIN/.libs/ and copies the resolved libgdal.so/libproj.so into the wheel. Exclude libraries you intend the host to provide so the repair does not abort:
auditwheel repair --plat manylinux_2_28_x86_64 \
--exclude libcrypto.so.1.1 \
--exclude libcurl.so.4 \
dist/*.whl
The deeper mechanics of how the loader chooses between vendored and system copies are covered in managing shared library paths in manylinux.
Verification
Run this sequence against the repaired wheel before merging. ELF tools read .so files, not .whl zips, so extract first:
# 1. Confirm the injected RPATH
unzip -o -q wheelhouse/*.whl -d /tmp/wh
readelf -d /tmp/wh/osgeo/_gdal*.so | grep -i rpath
# Expected: 0x...(RUNPATH) Library runpath: [$ORIGIN/.libs]
# 2. Confirm no system libgdal leaks in
ldd /tmp/wh/osgeo/_gdal*.so | grep -iE "gdal|proj"
# Expected: libgdal.so.* => .../osgeo/.libs/libgdal-*.so (never /usr/lib/...)
# 3. Confirm runtime GDAL matches the build
python -c "from osgeo import gdal; print(gdal.VersionInfo('RELEASE_NAME'))"
# Expected: 3.8.4 (must equal build-time GDAL_VERSION)
# 4. Confirm the wheel metadata tag
unzip -q -c wheelhouse/*.whl '*.dist-info/WHEEL' | grep Tag
# Expected: Tag: cp310-cp310-manylinux_2_28_x86_64
If step 2 prints a path under /usr/lib or /usr/local/lib, the vendoring failed and the import will break on any host without that exact system library.
Pitfalls & Alternatives
- Patching
LD_LIBRARY_PATHto “find” libgdal. Exporting the system GDAL directory makes the import succeed on your machine but bakes in the very ABI mismatch you are trying to remove — the linker now prefers a library that does not match the wheel’s compile target, so it breaks on every other host. Fix theRPATHwithauditwheel repairinstead of widening the search path. pip install --force-reinstall gdalto “get a fresh copy.” Reinstalling pulls the same prebuilt wheel from the index; if its native tag never matched your runtime, nothing changes. The mismatch is between the wheel’s C-API and yourlibgdal, not a corrupted download — rebuild against the target runtime.- Bumping the platform tag to the newest manylinux for “compatibility.” Targeting
manylinux_2_35does not make a wheel more portable; it raises the glibc floor and triggersGLIBC_2.NN not foundon older hosts. Pick the oldest base image your deployment fleet still runs, and weigh the glibc-vs-musl trade-off in manylinux2014 vs musllinux for spatial libs.
Related
- C-API vs CPython ABI Compatibility — the parent cluster explaining the interpreter-side ABI contract and
abi3wheel tagging. - Managing shared library paths in manylinux — how
RPATH,RUNPATH, and$ORIGINdecide whichlibgdalloads. - Why vendoring PROJ causes wheel bloat — the size cost of the self-contained approach this fix depends on.
- Step-by-step C-extension lifecycle for Python GIS — where compile, link, and repair sit in the full wheel pipeline.
Further reading: the platform-tag rules referenced above are defined by the PyPA manylinux specification (PEP 599).