Setting RPATH and RUNPATH for vendored PROJ
This page answers one question: your wheel bundles libproj but the loader keeps finding the host’s copy — or finds yours and then cannot find what it depends on — so how do you write the right run path into every object at link time rather than relying on the repair step to guess? It sits inside the Shared Library Path Resolution section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the linker flags, the CMake wiring and the difference the dtags choice makes.
Context & Root Cause
RPATH and RUNPATH are entries in an object’s dynamic section listing directories the loader should search for that object’s dependencies. They are per object: the extension’s entry helps the extension, and helps nothing else. The token $ORIGIN expands at load time to the directory containing the object doing the asking, which is what makes a wheel relocatable — the same wheel works in a virtual environment, a system prefix or a container image without knowing where it landed.
Repair tools write these entries automatically, and the reason to understand them anyway is that the automation has two gaps. First, a library the tool did not bundle — because it was linked statically, or because it was loaded through dlopen rather than declared — gets no entry and no copy. Second, the choice between RPATH and RUNPATH semantics determines whether a user’s LD_LIBRARY_PATH can override your bundled copy, which is a policy decision the tool makes by inheriting the linker’s default rather than by asking you.
Solution / Fix
This targets GNU ld/lld on Linux with GCC 13 or Clang 16; macOS uses @loader_path and is covered at the end.
1. Write the run path at link time
# For the extension: reach the sibling .libs directory
LDFLAGS="-Wl,-rpath,'\$ORIGIN/../mypkg.libs' -Wl,--enable-new-dtags"
# For a vendored library being installed into that directory
LDFLAGS="-Wl,-rpath,'\$ORIGIN' -Wl,--enable-new-dtags"
The single quotes matter: $ORIGIN must reach the linker literally, not be expanded by the shell. A run path containing the build machine’s home directory is the classic symptom of getting this wrong.
2. Do the same through CMake
set_target_properties(_ext PROPERTIES
INSTALL_RPATH "$ORIGIN/../mypkg.libs"
BUILD_WITH_INSTALL_RPATH ON # write the install RPATH at build time
INSTALL_RPATH_USE_LINK_PATH OFF) # never bake absolute link paths
INSTALL_RPATH_USE_LINK_PATH OFF is the important line. Left on, CMake helpfully appends the absolute directories it linked against — which are build-machine paths that mean nothing on a user’s system and, worse, may exist and contain the wrong library.
3. Choose the precedence deliberately
# RUNPATH: LD_LIBRARY_PATH wins — the user can override you (modern default)
LDFLAGS="$LDFLAGS -Wl,--enable-new-dtags"
# RPATH: your bundled copy wins unconditionally
LDFLAGS="$LDFLAGS -Wl,--disable-new-dtags"
4. Let the repair step verify rather than rewrite
auditwheel repair -w dist/repaired dist/*.whl
readelf -d dist/repaired/mypkg/_ext*.so | grep -E 'RPATH|RUNPATH'
Verification
# 1. Every object carries an $ORIGIN-relative entry — none absolute
unzip -o dist/repaired/*.whl -d /tmp/w >/dev/null
for f in $(find /tmp/w -name '*.so*'); do
p=$(readelf -d "$f" | awk -F'[][]' '/RUNPATH|RPATH/{print $2}')
case "$p" in
'$ORIGIN'*) : ;;
'') echo "MISSING: $f" ;;
*) echo "ABSOLUTE: $f -> $p" ;;
esac
done
# expected: no output
# 2. The bundled PROJ is the one that loads, not the host's
docker run --rm -v "$PWD/dist/repaired:/d" python:3.12-slim bash -c '
pip install -q /d/*.whl
LD_DEBUG=libs python -c "import pyproj" 2>&1 | grep -m1 "libproj.*init"'
# expected: a path inside site-packages, never /usr/lib
# 3. It still resolves when installed somewhere unusual
python -m venv /tmp/odd/place && /tmp/odd/place/bin/pip install -q dist/repaired/*.whl
/tmp/odd/place/bin/python -c "import pyproj; print('relocatable')"
The LD_DEBUG=libs check in step two is the definitive answer to “which copy is being used”. It prints every directory the loader tried and the path that won, which settles the question in one run rather than by inference.
RPATH or RUNPATH: Deciding Who Wins
The two entries differ in exactly one respect — where they sit relative to LD_LIBRARY_PATH in the search order — and that difference is a policy choice about your users.
The recommendation deserves its qualifier. RUNPATH is right for most packages because overriding is occasionally legitimate — a user with a proprietary GDAL driver, or a distribution packaging your code against system libraries. But it means an LD_LIBRARY_PATH set for some entirely unrelated tool can silently substitute a different PROJ, and PROJ substitutions produce different coordinates rather than errors. Pairing RUNPATH with a runtime check that reports the loaded library’s version — visible through the same mechanism the reproducible builds chapter recommends for inventories — gives you the flexibility without the silent failure.
The macOS and Windows Analogues
The concept survives; the spelling and the enforcement do not.
The macOS difference that costs time is the signature. Every install-name rewrite invalidates the signature of the object rewritten, so the ordering is merge, rewrite, then sign — and any later step that touches the bytes, including stripping, restarts that sequence. delocate handles the common path; custom post-processing is where it breaks.
Pitfalls & Alternatives
Letting the shell expand $ORIGIN. Without quoting, the shell substitutes an empty string and the linker writes an entry pointing at /../mypkg.libs. The wheel then works only in the unlikely case that such a path exists.
Relying on LD_LIBRARY_PATH as the fix. It makes the import succeed on the machine where you set it and changes nothing about the artifact. Worse, it applies process-wide, so it can redirect another package’s bundled libraries too.
Setting the run path only on the extension. The extension finds libgdal; libgdal then looks for libproj using its own entry, finds none, and falls through to the host. This is the partial-repair failure described in managing shared library paths in manylinux and the reason the verification loops over every object.
Leaving INSTALL_RPATH_USE_LINK_PATH on in CMake. It appends the absolute directories used at link time, so the shipped object carries build-machine paths. They are usually harmless and occasionally catastrophic, when such a directory exists on a user’s system with a different library in it.
Frequently Asked Questions
Should I set the run path myself if the repair tool writes one anyway?
Setting it at link time makes the intent explicit and covers the objects the tool does not touch — anything loaded through dlopen, and any library you install into the wheel by hand. Where both apply, the tool’s rewrite wins, and having authored the same value means the two never disagree.
Can a run path contain more than one directory?
Yes, colon-separated, and it is occasionally useful — an extension that needs both a sibling directory and a nested one, for instance. Keep the list short: each entry is a directory the loader will stat for every dependency it resolves.
Does $ORIGIN work inside a container or a virtual environment?
It works anywhere, because it is resolved by the loader from the object’s own path at load time. That is precisely the property that makes a wheel relocatable, and it is why an absolute path baked at build time is never an acceptable substitute.
What happens if two bundled libraries have the same soname?
The first one loaded wins for both, which is why repair tools rename bundled libraries with a content hash. Hand-vendored libraries that keep their original names can collide with another package’s copy in exactly the way the renaming exists to prevent.
Can I inspect the run path of an installed package without unpacking a wheel?
Yes — the installed files are ordinary shared objects, so readelf -d on the extension inside site-packages reports the same entries. That is often the faster route when diagnosing a user’s environment, because it describes what they actually have rather than what the wheel contained.
Does a run path affect the platform tag?
Not directly. The tag is computed from symbol versions rather than from search paths. Indirectly it matters a great deal, because a missing entry causes the repair step to leave a library external, and external references are what push the computed tag away from what you intended.
Is it worth setting a run path on a package with no bundled libraries?
No — with nothing to point at, the entry is noise. It becomes relevant the moment the package starts vendoring anything, which is worth knowing because the change from borrowing to bundling is where these settings suddenly matter and where a build that never needed them acquires the requirement.
Related
- Shared library path resolution — the parent guide on how the loader searches and in what order.
- Managing shared library paths in manylinux — what the repair step writes, and how
$ORIGINexpands per object. - Reading objdump and readelf output for spatial wheels — inspecting the entries these flags produce.