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.

Which run path each object in a repaired wheel needs Three objects with different requirements. The extension module lives inside the package and needs a run path one level up and into the sibling libs directory. Each bundled library lives in that libs directory and needs a run path pointing at its own directory so it can find its peers. A library that depends on another bundled library needs the same entry for the same reason. A note records that omitting the entry on the bundled libraries is the most common partial-repair failure. mypkg/_ext.abi3.so RUNPATH $ORIGIN/../mypkg.libs reaches the sibling directory the repair step created mypkg.libs/libgdal-*.so.34 RUNPATH $ORIGIN so libgdal finds libproj sitting beside it mypkg.libs/libproj-*.so.25 RUNPATH $ORIGIN so libproj finds libsqlite3 and libtiff beside it omit the last two and the wheel works only where PROJ's own dependencies already exist on the host

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.

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

Search precedence and what each choice means for users With the legacy RPATH entry the embedded path is consulted before LD_LIBRARY_PATH, so the bundled library always wins and a user cannot substitute their own. With the modern RUNPATH entry LD_LIBRARY_PATH is consulted first, so a user can deliberately override — and can also break the wheel accidentally with a variable set for an unrelated reason. Below, guidance: choose RUNPATH plus a clear error message for most packages, and RPATH only when correctness demands the bundled copy. RPATH — you win 1 · the bundled copy, always 2 · LD_LIBRARY_PATH — cannot override 3 · system directories predictable; a user who deliberately wants the system PROJ has no way to get it RUNPATH — the user can win 1 · LD_LIBRARY_PATH, if set 2 · the bundled copy 3 · system directories respects an explicit choice; also lets an unrelated variable in a shell profile break the wheel recommended: RUNPATH, plus an import-time check that reports which library actually loaded choose RPATH only when a mismatched library would produce wrong numbers rather than an error

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.

Run-path equivalents on macOS and Windows On Linux the loader searches an embedded RPATH or RUNPATH containing dollar-ORIGIN. On macOS each dependency is recorded as an install name, and loader_path plays the role of dollar-ORIGIN; rewriting an install name invalidates the code signature so the object must be re-signed. On Windows nothing is embedded at all, so the package must register its directory at import time with os.add_dll_directory. A note records that only the Linux and macOS forms are properties of the file. Linux -Wl,-rpath,'$ORIGIN/../pkg.libs' embedded in the file; expands per object at load time macOS install_name @loader_path/../.dylibs also embedded; rewriting it invalidates the signature — re-sign afterwards Windows os.add_dll_directory(pkg_libs) nothing is embedded; the registration happens in Python at import

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.