Fixing “libgdal.so: cannot open shared object file”

This page answers one question: your extension imported fine in CI but a user gets ImportError: libgdal.so.34: cannot open shared object file: No such file or directory, so how do you make the wheel carry its own GDAL instead of hoping the host has the right SONAME? It sits inside the Debugging Import Errors and Linker Failures section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the ldd diagnosis, the auditwheel repair fix, and the clean-container proof.

Why an unrepaired wheel cannot find libgdal at runtime On the build host the extension links libgdal.so.34 from /usr/lib and imports. Shipped as-is to a target host without that exact SONAME, the loader cannot open the shared object and import fails. After auditwheel repair copies libgdal into the wheel's bundled libs directory and rewrites RPATH to $ORIGIN, the same wheel imports on the bare target. build host /usr/lib/libgdal.so.34 links · imports ✓ bare target host no libgdal.so.34 cannot open ✗ repaired wheel .libs/libgdal.so.34 $ORIGIN · imports ✓ ship unrepaired → auditwheel repair copies .so into wheel · sets RPATH=$ORIGIN

Context & Root Cause

cannot open shared object file means the dynamic loader walked its entire search path — RPATH, LD_LIBRARY_PATH, the default /etc/ld.so.cache, and the standard directories — and never found a file whose name matches the SONAME baked into your extension. The version suffix is the giveaway: libgdal.so.34 is GDAL’s ABI SONAME, and it exists only where GDAL 3.8/3.9 is installed. Your CI runner had it because the build image installed GDAL; the user’s python:3.12-slim container or fresh VM does not.

The root cause is therefore not the loader — it is doing exactly what it should — but a wheel that was never made self-contained. A distributable geospatial wheel must carry its native dependencies, because you cannot assume any given SONAME exists on the target. This is the vendoring contract from the parent reference: build, then repair so the loader finds libgdal next to the extension rather than on the host. The trade-offs of vendoring versus borrowing are laid out in vendoring PROJ and GDAL vs system libraries.

Solution / Fix

This targets auditwheel 6.x on Linux (delocate 0.11+ on macOS) and a wheel built against GDAL 3.8.x.

1. Confirm the missing SONAME with ldd

unzip -o dist/*.whl -d /tmp/w >/dev/null
ldd /tmp/w/*.so | grep -i gdal
# symptom:  libgdal.so.34 => not found

=> not found confirms class A (missing SONAME) rather than an undefined-symbol or precedence problem.

2. Repair the wheel so GDAL is bundled

auditwheel repair inspects the external links, copies each required .so into a .libs/ directory inside the wheel, and rewrites the extension’s RPATH to $ORIGIN so the loader looks next to it:

# LD_LIBRARY_PATH must point at the build-time GDAL so auditwheel can find
# the library it needs to copy in.
LD_LIBRARY_PATH=/opt/gdal/lib \
  auditwheel repair --plat manylinux_2_28_x86_64 -w dist/repaired/ dist/*.whl

3. Confirm the repaired wheel is self-contained

unzip -o dist/repaired/*.whl -d /tmp/r >/dev/null
ls /tmp/r/*.libs/            # expect libgdal-<hash>.so.34, libproj..., libgeos...
readelf -d /tmp/r/*.so | grep RUNPATH
# expect: [$ORIGIN/../<pkg>.libs]

The $ORIGIN/../<pkg>.libs RUNPATH is what makes the wheel relocatable — the exact loader mechanics are covered in managing shared library paths in manylinux.

Verification

# The only test that counts: import in a container with NO gdal installed
docker run --rm -v "$PWD/dist/repaired:/d" python:3.12-slim \
  bash -c "pip install /d/*.whl && python -c 'from osgeo import gdal; print(gdal.__version__)'"
# expected: 3.8.x  (no 'cannot open shared object file')
# And the platform tag must be versioned, never plain 'linux'
auditwheel show dist/repaired/*.whl | grep -i platform
# expected: manylinux_2_28_x86_64

A pass shows a version string printed from a bare image. If it still raises cannot open shared object file, the repair did not run against this wheel — confirm you installed the file from dist/repaired/, not the original dist/.

Pitfalls & Alternatives

Setting LD_LIBRARY_PATH on the target as a “fix”. Exporting LD_LIBRARY_PATH=/path/to/gdal on the user’s machine makes the import succeed and hides the real defect — the wheel is still not portable, and the next user hits the same wall. Repair the wheel; do not patch the environment.

Repairing with the wrong LD_LIBRARY_PATH at build time. If auditwheel cannot locate the build-time libgdal, it reports cannot find libgdal.so.34 during repair. Point LD_LIBRARY_PATH at the directory holding the GDAL you linked against.

Bundling GDAL but forgetting proj.db. GDAL loads, then PROJ raises a data error because the datum database was not packaged — a different failure covered in bundling proj.db datum grids in a wheel.

What Repair Actually Changes Inside the Wheel

It is worth knowing exactly what auditwheel repair does to a wheel, because the three things it changes are the three things to inspect when it appears not to have worked.

A wheel before and after auditwheel repair Before repair, the wheel contains only the package and its extension module, which records a NEEDED entry for libgdal.so.34 with no RUNPATH, and carries a plain linux_x86_64 platform tag. After repair, a dot-libs directory holds copies of libgdal, libproj, libgeos and their dependencies with hashed filenames, the extension records a RUNPATH of dollar-ORIGIN slash dot-dot slash package dot libs, and the wheel is renamed with a manylinux platform tag. before — depends on the host mypkg/__init__.py mypkg/_ext.cpython-39-…so NEEDED libgdal.so.34 NEEDED libproj.so.25 RUNPATH (none) mypkg-1.0.dist-info/ tag: linux_x86_64 PyPI refuses this tag on upload imports only where the host already has GDAL 3.8 installed after — carries what it needs mypkg/__init__.py mypkg/_ext.cpython-39-…so NEEDED libgdal-4f2a1c.so.34 RUNPATH $ORIGIN/../mypkg.libs mypkg.libs/ libgdal-4f2a1c.so.34 libproj-9b31e7.so.25 libgeos_c-2c80f4.so.1 + deps tag: manylinux_2_28_x86_64 imports on any glibc 2.28+ host, with or without GDAL installed auditwheel repair three changes: libraries copied in with hashed names, RUNPATH rewritten to $ORIGIN, platform tag recomputed and the file renamed if any one of the three is missing from your wheel, the repair did not run on the file you are testing

The hashed filenames deserve a note, because they are frequently mistaken for a bug. auditwheel renames each bundled library to include a content hash — libgdal-4f2a1c.so.34 rather than libgdal.so.34 — so that two wheels bundling different GDAL builds can be installed into the same environment without one overwriting or shadowing the other. The extension’s NEEDED entry is rewritten to match, so nothing in your code needs to know. What it does mean is that any code calling ctypes.CDLL("libgdal.so.34") by name will fail on a repaired wheel, and the fix is to go through the extension module rather than to defeat the renaming.

The Search That Failed

The error is the end of a search, and seeing the search laid out makes it obvious why adding a library to the build machine never helps.

The loader's search for libgdal.so.34 on a user's machine The extension records a NEEDED entry for libgdal.so.34. The loader checks DT_RPATH, then LD_LIBRARY_PATH, then DT_RUNPATH, then the ld.so cache, then the default directories, and finds nothing, raising cannot open shared object file. A second path shows the repaired wheel, where DT_RUNPATH points at the package's own libs directory and the search succeeds at the third step without ever consulting the host. unrepaired wheel DT_RPATH — absent LD_LIBRARY_PATH — unset on the user's box DT_RUNPATH — absent /etc/ld.so.cache — no libgdal.so.34 /lib, /usr/lib — no libgdal.so.34 → ImportError the build machine had GDAL in /opt; the user's machine has none, and nothing in the wheel points anywhere useful after auditwheel repair DT_RPATH — absent LD_LIBRARY_PATH — still unset DT_RUNPATH = $ORIGIN/../mypkg.libs → found ld.so.cache — never reached default directories — never reached the search now succeeds inside the package, so the host's GDAL — present or absent, old or new — is irrelevant $ORIGIN is expanded by the loader at run time to the directory of the object being loaded, which is what makes the wheel relocatable

$ORIGIN is the whole trick: it is expanded by the loader relative to the object doing the asking, so the same wheel works whether it is installed into a virtual environment, a system prefix or a container image, without anything on the target knowing where it landed.

Why It Worked in CI and Failed for the User

This error has a characteristic asymmetry — green pipeline, broken install — and the asymmetry has exactly four causes worth checking in order.

The wheel that was tested was not the wheel that was published. The build produces dist/*.whl; the repair writes dist/repaired/*.whl; and a test step that globs dist/*.whl picks up the unrepaired original, tests it successfully on a runner that has GDAL, and uploads the repaired one — or worse, uploads the original. Naming the directories so the two cannot be confused, and asserting the platform tag before upload, closes this permanently.

The test ran on the build image. A test step inside the same container that compiled GDAL will always pass, because /opt/gdal/lib is right there and often on LD_LIBRARY_PATH. The load gate has to run in an image with no geospatial libraries at all; anything else is measuring the build environment.

The repair silently skipped a library. auditwheel copies what it can find. If a dependency was not on LD_LIBRARY_PATH at repair time, the tool reports it as an external reference and continues, producing a wheel that bundles GDAL but not, say, libproj. The wheel then works on hosts that happen to have a compatible PROJ and fails elsewhere. Reading auditwheel show output after repair — not before — catches this in one line.

The user’s platform is not the one you tested. A manylinux_2_28 wheel will not install on a glibc 2.17 system; pip falls back to the sdist and the user sees a compilation failure rather than an import error, which is a different report for the same underlying cause. Publishing the oldest tag you can support, and saying which that is in the README, prevents most of these.

# The assertion that would have caught it: run on the artifact about to be uploaded
auditwheel show dist/repaired/*.whl \
  | tee /dev/stderr \
  | grep -qE 'manylinux_[0-9]+_[0-9]+' || { echo "BLOCK: unrepaired wheel"; exit 1; }

Frequently Asked Questions

Can I bundle GDAL without auditwheel by copying the .so files myself?

You can, and people do, but you then own three details the tool handles: rewriting every object’s RUNPATH so the copies are found, renaming to avoid collisions with other packages, and computing the correct platform tag from the glibc symbols actually referenced. Getting the first two right by hand is feasible; the third is where hand-rolled repairs usually go wrong, producing a tag that promises more compatibility than the binary delivers.

What is the difference between this error and version GDAL_3.8 not found?

This one means no file with that soname was found anywhere. The version error means a file was found and it is too old to contain the symbol version requested — the host’s GDAL won over the bundled one. The first is a packaging failure, the second is a loader-precedence failure, and they need different fixes even though both mention GDAL and both appear at import.

Does the same approach work for GDAL’s format plugins?

Partly. Drivers built as separate shared objects are dlopened at runtime by path rather than linked, so auditwheel does not see them as dependencies and will not bundle them. Shipping plugins means copying them in deliberately and setting GDAL_DRIVER_PATH at import to point inside the package — otherwise GDAL loads the plugins it finds on the host, which reintroduces exactly the dependency the repair removed.

Should the package fail loudly if the bundled libraries are missing?

Yes, and a two-line check in __init__.py pays for itself. Importing the extension inside a try and re-raising with a message that names the expected .libs directory turns an opaque loader error into an actionable one, and gives you somewhere to record the vendored versions for bug reports.