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:

GDAL wheel error-signature decision tree Starting from an ImportError or undefined-symbol failure, four branches each map a distinct error signature to its fix: a missing RPATH, a GDAL C-API drift, a too-new platform tag, and a tripped ABI guard. ImportError / undefined symbol Error signature? 1 cannot open libgdal.so.NN RPATH missing — auditwheel repair adds $ORIGIN/.libs 2 undefined symbol: GDALOpenEx C-API drift — pin build to runtime, re-vendor 3 GLIBC_2.NN not found Platform tag too new — rebuild on older manylinux 4 compiled against GDAL X.Y ABI guard tripped — align GDAL_VERSION, rebuild

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.

Two independent ABI layers in a GDAL wheel pip selects the wheel by matching the CPython interpreter tag only. At runtime the ELF loader separately resolves libgdal: the correct path is the vendored $ORIGIN/.libs copy, but a system libgdal on LD_LIBRARY_PATH can be bound first, causing an undefined-symbol failure. Layer 1 · pip Layer 2 · ld.so pip install gdal checks interpreter tag only ELF loader (ld.so) search order decides site-packages/osgeo/ _gdal.cpython-310.so C-extension · ABI tag cp310 libgdal-3.8.so vendored · $ORIGIN/.libs libgdal.so.30 system · GDAL 3.4 (wrong ABI) tag cp310 OK needs libgdal.so.32 OK · matches LD_LIBRARY_PATH loaded first — hijack X undefined symbol: GDALOpenEx Loader can bind the system libgdal first → ABI break. Fix: pin RPATH to $ORIGIN/.libs.

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_PATH to “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 the RPATH with auditwheel repair instead of widening the search path.
  • pip install --force-reinstall gdal to “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 your libgdal, not a corrupted download — rebuild against the target runtime.
  • Bumping the platform tag to the newest manylinux for “compatibility.” Targeting manylinux_2_35 does not make a wheel more portable; it raises the glibc floor and triggers GLIBC_2.NN not found on 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.

Further reading: the platform-tag rules referenced above are defined by the PyPA manylinux specification (PEP 599).