Managing shared library paths in manylinux
A geospatial wheel that builds cleanly but fails auditwheel repair with contains external shared libraries or raises ImportError: libproj.so.25: cannot open shared object file is almost always carrying the wrong dynamic-linker run path — this page shows how to embed $ORIGIN-relative RPATH so a pyproj, rasterio, or GDAL extension resolves every native dependency from inside the wheel. It sits under the Shared Library Path Resolution guide, part of the broader Geospatial C-Extension Fundamentals & ABI Architecture reference.
At a high level, auditwheel repair turns a host-linked extension into a self-contained wheel:
Context & Root Cause
manylinux wheels (PEP 513/571/600) must embed every non-stdlib C dependency rather than rely on system packages, so libproj and libgdal have to be discoverable from a path baked into the binary itself. The dynamic linker reads that path from one of two ELF dynamic tags: the legacy DT_RPATH or the newer DT_RUNPATH. Modern binutils default to --enable-new-dtags, which emits DT_RUNPATH — and RUNPATH is searched after LD_LIBRARY_PATH, so any environment override on the deployment host can hijack symbol resolution and load a mismatched system library. That non-determinism is exactly what manylinux forbids, which is why auditwheel rejects RUNPATH outright. The fix is to force DT_RPATH pointing at $ORIGIN/.libs, the relocatable token the loader expands to the directory holding the .cpython-*.so at runtime. Getting this wrong is the single most common reason an otherwise valid wheel fails repair or import.
Solution / Fix
Prerequisites: build inside a pinned quay.io/pypa/manylinux_2_28_x86_64 image (see the manylinux and manyARM Docker base images that anchor glibc compliance), with auditwheel ≥ 6.x, patchelf ≥ 0.18, and PROJ/GDAL compiled into an isolated prefix.
Start by matching your CI error signature to its root cause, then apply the corresponding step below:
| Error Signature | Root Cause | Fix Vector |
|---|---|---|
auditwheel: error: cannot repair "..." to "manylinux_2_28_x86_64" ... cannot satisfy ... libgdal.so.32 |
auditwheel cannot locate the dependency on its search paths, or the library baked in an absolute host path. |
Expose the isolated prefix via LD_LIBRARY_PATH during repair; verify PKG_CONFIG_PATH during compilation (Step 2–3). |
wheel is eligible for a manylinux_2_28 ... contains external shared libraries: libproj.so.25 |
The extension references a .so outside the wheel’s .libs, usually from RUNPATH injection. |
Force DT_RPATH via --disable-new-dtags (Step 1). |
ImportError: libproj.so.25: cannot open shared object file: No such file or directory |
Runtime resolution fails because the $ORIGIN path is malformed or LD_LIBRARY_PATH shadowed RUNPATH. |
Validate DT_RPATH with readelf and confirm the $ORIGIN/.libs layout (Verification). |
1. Enforce deterministic RPATH at compile time
Override the default binutils behaviour during the extension build so the linker writes DT_RPATH instead of DT_RUNPATH:
# Escape $ORIGIN to prevent premature shell expansion
export LDFLAGS="-Wl,-rpath,\$ORIGIN/../.libs -Wl,--disable-new-dtags"
export CFLAGS="-fPIC -O2"
The -Wl,--disable-new-dtags flag forces DT_RPATH; the $ORIGIN token resolves to the directory containing the compiled .so at load time, letting the loader find vendored dependencies without any environment variable. If you drive the build through the scikit-build-core backend that translates pyproject.toml into CMake invocations, set the same flags through CMAKE_SHARED_LINKER_FLAGS so CMake does not reintroduce --enable-new-dtags.
2. Isolate geospatial dependencies
Never link against host-system libraries inside a manylinux container. Compile PROJ and GDAL into a dedicated prefix without embedded rpaths so they cannot bake absolute host paths into their own .so files. Autotools transitive deps (libtiff, libgeotiff) accept --disable-rpath; modern PROJ and GDAL build with CMake, so pass -DCMAKE_SKIP_INSTALL_RPATH=ON:
# Inside the manylinux container — autotools dependency example
./configure \
--prefix=/opt/geospatial \
--disable-rpath \
--disable-static \
--enable-shared
# Modern PROJ/GDAL build with CMake instead of ./configure:
# cmake -DCMAKE_INSTALL_PREFIX=/opt/geospatial -DCMAKE_SKIP_INSTALL_RPATH=ON \
# -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=OFF ..
make -j"$(nproc)" && make install
# Export the isolated paths for the Python extension build
export PKG_CONFIG_PATH="/opt/geospatial/lib/pkgconfig"
export GDAL_CONFIG="/opt/geospatial/bin/gdal-config"
export PROJ_INCLUDE="/opt/geospatial/include"
export PROJ_LIB="/opt/geospatial/lib"
This isolation is the same discipline analysed in why vendoring PROJ causes wheel bloat: if PROJ or GDAL embeds /opt/geospatial/lib as an absolute path, auditwheel cannot rewrite it to $ORIGIN/.libs and the repair fails with an external-library violation.
3. Execute targeted auditwheel repair
Run auditwheel with the vendor prefix visible to the ELF loader. The tool walks each DT_NEEDED entry, copies non-whitelisted libraries into the wheel’s .libs directory, and patches the run path:
LD_LIBRARY_PATH=/opt/geospatial/lib auditwheel repair dist/*.whl \
--plat manylinux_2_28_x86_64 \
--exclude libstdc++.so.6 \
--exclude libgcc_s.so.1 \
-w wheelhouse/
Use --exclude only for compiler runtime libraries guaranteed by the base image (libstdc++, libgcc_s) that would otherwise be bundled twice. Never exclude the geospatial core libraries libproj, libgdal, or libgeos — those must be vendored.
Verification
Validate the repaired wheel before publishing. Do not rely on ldd alone, as it follows symlinks and is skewed by the host environment.
# 1. Confirm DT_RPATH (not RUNPATH) was injected
unzip -q wheelhouse/*.whl -d /tmp/wheel_check
readelf -d /tmp/wheel_check/your_ext/*.cpython-*.so | grep -E 'RPATH|RUNPATH'
Expected output — RPATH, never RUNPATH:
0x000000000000000f (RPATH) Library rpath: [$ORIGIN/../.libs]
# 2. Confirm bundling and that auditwheel sees zero external deps
patchelf --print-rpath /tmp/wheel_check/your_ext/*.cpython-*.so
ls /tmp/wheel_check/your_ext/.libs/
auditwheel show wheelhouse/*.whl
The .libs directory must contain libproj.so.*, libgdal.so.*, and any non-whitelisted transitive deps, and auditwheel show must report the manylinux_2_28_x86_64 tag with no external references.
# 3. Runtime smoke test in a clean container with no LD_LIBRARY_PATH
docker run --rm -v "$(pwd)/wheelhouse:/wheels" quay.io/pypa/manylinux_2_28_x86_64 \
bash -c "pip install /wheels/*.whl && python -c 'import your_ext; print(\"ABI OK\")'"
A clean ABI OK confirms the loader resolves every vendored library through RPATH with no host interference.
Pitfalls & Alternatives
- “Just set
LD_LIBRARY_PATHat runtime.” This appears to fix the import locally but produces a non-portable wheel: any host without that variable — or with a conflicting systemlibgdal— breaks, and the same override is what makesRUNPATHresolution unsafe in the first place. Fix the embeddedRPATHinstead of patching the environment. - Leaving binutils on its
--enable-new-dtagsdefault. The link succeeds and the wheel installs, butauditwheelemitscontains external shared librariesbecauseDT_RUNPATHis not a pathauditwheeltrusts. Always pair-Wl,-rpathwith-Wl,--disable-new-dtags. - Patching the wheel by hand with
patchelf --set-rpathafter the fact. It can set the run path but does not copy the dependency into.libs, so the wheel still references a host library. Letauditwheel repaircopy and patch in one pass; reach forpatchelfonly to inspect, not to repair. If a mismatched native library is the actual cause, follow how to fix ABI version mismatch in GDAL wheels rather than rewriting paths.
Related
- Shared Library Path Resolution — the parent guide covering
RPATH/RUNPATH,@loader_path, and the Windows DLL search model across all three platforms. - Vendoring PROJ and GDAL vs system libraries — why the isolated-prefix discipline in Step 2 is mandatory rather than optional.
- manylinux2014 vs musllinux for spatial libs — choosing the base image whose glibc/musl ABI your repaired wheel must match.
Further reading: the run-path tags and platform rules above are defined by the PyPA manylinux specifications, PEP 571 and PEP 600.