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 section, 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 section.

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.

Two Versions That Both Have to Match

“ABI mismatch” is used for two different failures in geospatial packaging, and they are fixed in different files. Separating them takes one look at which pair disagrees.

The CPython ABI contract and the GDAL soname contract are separate Two independent contracts are drawn. The upper one links the extension to the interpreter through the limited API and the abi3 tag, and is broken by using a non-limited symbol or declaring the wrong floor. The lower one links the extension to libgdal through the soname and symbol versions, and is broken by compiling against one GDAL minor version and loading another. Each contract has its own diagnostic command. _gdal extension one object, two contracts CPython 3.9+ limited API · abi3 tag breaks when a non-limited symbol is used libgdal.so.34 soname · symbol versions breaks when build and load GDAL differ diagnose: nm -D | grep ' U _Py' diagnose: readelf -d | grep NEEDED

The practical value of separating them is that the first is fixed in source and compile flags, while the second is fixed in the pinning and the repair step — and applying the wrong remedy to the wrong contract is how an afternoon disappears.

Frequently Asked Questions

Which of the two contracts is more likely to be at fault?

The native one, by a wide margin. The interpreter contract is enforced at compile time once Py_LIMITED_API is defined, so violations are caught early and rarely reach a wheel. The GDAL soname contract depends on what was present at build time and what is present at load time, and those two environments are only as similar as your packaging makes them.

Does pinning GDAL exactly solve the problem?

It solves half of it. Pinning fixes what you build against; the repair step is what guarantees that the same library travels with the wheel. A pinned build with no repair produces a wheel that demands an exact GDAL version from the user’s machine, which is stricter than the original problem and just as broken.

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