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.
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.
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.
$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.
Related
- Debugging Import Errors and Linker Failures — the parent guide that classifies this error against undefined-symbol and version-mismatch failures.
- Vendoring PROJ and GDAL vs system libraries — why a distributable wheel must carry its own
libgdal. - Managing shared library paths in manylinux — how the
$ORIGINRUNPATH that repair writes resolves at load time.