Deterministic Dynamic Linking and RPATH

Updated

September 12, 2026

Deterministic Dynamic Linking and RPATH

On a conventional Linux system, rg asks the dynamic linker for libc.so.6 and takes whatever /lib currently holds. A glibc upgrade then changes every running binary at once. The boring default is: bake the exact store path of every needed library into the ELF RUNPATH, and leave LD_LIBRARY_PATH alone.

Mental model

When Nix links a binary it sets DT_RUNPATH (or older DT_RPATH) to store directories:

ELF header
  RUNPATH = /nix/store/7r44p…-glibc-2.39/lib:/nix/store/…-gcc-…/lib

ld.so looks there, not in /lib, not in /usr/lib. Two rg binaries can sit on the same machine, each with its own glibc, and neither notices the other.

Reference scanning then records those store paths as runtime references. Garbage collection will not delete glibc while a rooted rg still points at it.

rg  --RUNPATH-->  glibc-2.39/lib
      --references (db.sqlite) -->  same glibc path

The program interpreter (ld-linux) is also a store path. A Nix binary copied to a machine without /nix dies with No such file or directory even though the file is there.

Worked examples

Case 1: Read RUNPATH off a real binary

rg_bin=$(readlink -f $(which rg) 2>/dev/null || echo "$(nix path-info nixpkgs#ripgrep)/bin/rg")
readelf -d "$rg_bin" | grep -E 'RPATH|RUNPATH'

Output (shape):

 0x000000000000001d (RUNPATH)            Library runpath: [/nix/store/7r44p12l4b72j9m9l09k96yghz02f8n7-glibc-2.39/lib]

If rg is a wrapper script, follow it with readlink -f or inspect the path under nix path-info.

Case 2: Which libraries would actually load

ldd "$rg_bin"

Output (abbreviated):

    linux-vdso.so.1 (0x00007ffd…)
    libgcc_s.so.1 => /nix/store/…-xgcc-…-libgcc/lib/libgcc_s.so.1
    libc.so.6 => /nix/store/…-glibc-2.39/lib/libc.so.6
    /nix/store/…-glibc-2.39/lib/ld-linux-x86-64.so.2

Every => target is under /nix/store. Nothing resolves to /lib/x86_64-linux-gnu.

Case 3: The interpreter is a store path too

readelf -l "$rg_bin" | grep interpreter

Output:

      [Requesting program interpreter: /nix/store/…-glibc-2.39/lib/ld-linux-x86-64.so.2]

The kernel loads that ld-linux, not /lib64/ld-linux-x86-64.so.2. scp bin/rg to a host without /nix fails here, not because the bytes of rg are missing.

Case 4: Runtime closure matches RUNPATH

nix path-info -r $(nix path-info nixpkgs#ripgrep)

You should see glibc (and a few others), not gcc, not the source tarball. Compilers are build-time; RUNPATH is runtime. Confirm the same hash:

readelf -d "$rg_bin" | grep RUNPATH
nix path-info -r nixpkgs#ripgrep | grep glibc

Same 7r44p… (or whatever 26.05 pinned).

Case 5: LD_LIBRARY_PATH punches a hole

LD_LIBRARY_PATH=/usr/lib ldd "$rg_bin" | head

Depending on the host, ldd may now show /usr/lib for some libraries. That is a different program than the one Nix hashed. It may work today and segfault after a distro upgrade.

Nix wrappers sometimes set LD_LIBRARY_PATH for a specific package that was not patched. That wrapper is part of the derivation. Your shell profile setting a global LD_LIBRARY_PATH is not.

Vendor ELF files get nix-ld or buildFHSEnv, not export LD_LIBRARY_PATH in .bashrc.

To see Nix set RUNPATH, build a tiny C program:

# rpath-demo.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.stdenv.mkDerivation {
  pname = "desk-rpath";
  version = "1.0";
  src = pkgs.writeTextDir "main.c" ''
    int main(void) { return 0; }
  '';
  buildPhase = ''
    $CC $src/main.c -o desk-rpath
  '';
  installPhase = ''
    mkdir -p $out/bin
    cp desk-rpath $out/bin/
  '';
}
nix-build rpath-demo.nix
readelf -d result/bin/desk-rpath | grep -E 'RPATH|RUNPATH'

$CC (not gcc) is how stdenv injects the store RUNPATH. Hardcoding gcc from the host skips that. NIX_LDFLAGS / the bintools wrapper is what actually writes DT_RUNPATH. dontPatchELF = true skips Nix’s patchelf — only when you know the binary is already right (or static).

Vendor blobs you did copy into $out:

# patchelf.nix fragment
{
  nativeBuildInputs = [ autoPatchelfHook ];
  buildInputs = [ openssl zlib ];
}

autoPatchelfHook walks $out, sets interpreter + RUNPATH from buildInputs. That is how a downloaded desk-toolbox becomes a Nix package. It is not export LD_LIBRARY_PATH.

The trap

The trap is exporting LD_LIBRARY_PATH in .bashrc to make a vendor binary start. Every Nix binary in that shell now prefers the vendor’s libssl.so. The next OpenSSL update on the host becomes a Nix outage.

A second trap is patchelf --set-rpath /usr/lib on a store path. The path is read-only, and even if you copy it out, you have left the closure Nix knows about.

A third: calling host gcc in buildPhase so RUNPATH is empty and ldd shows /lib. A fourth: dontPatchELF = true copied from a gist so “the build is faster,” then libc.so.6 => not found.

The boring rule

  • Trust RUNPATH. Inspect it with readelf -d and ldd. Use $CC, not host gcc.
  • Runtime references in the Nix database should match those libraries.
  • Do not set global LD_LIBRARY_PATH, LD_PRELOAD, or DYLD_* to “fix” Nix.
  • A binary copied off the store needs its interpreter and RUNPATH closure (nix copy, not scp bin/rg).
  • Vendor ELF files get nix-ld, buildFHSEnv, or autoPatchelfHook — not a profile hack.

Try this

  1. Compare readelf -d $(which curl) on a Nix curl versus, if you have one, /usr/bin/curl. Note where libc lives in each.
  2. Run nix path-info -Shr nixpkgs#ripgrep and find glibc in the closure. Confirm the same hash appears in RUNPATH.
  3. file $(readlink -f $(which rg)) — confirm it is dynamically linked. (A few Nix packages are static; they have no RUNPATH. That is also fine.)
  4. In a subshell, export LD_LIBRARY_PATH=/does/not/exist and run rg --version. Then unset it. If something fails only with the export, you have felt the hole.