Dependency Closures and Graphs

Updated

September 12, 2026

Dependency Closures and Graphs

A store path is never alone. The boring default is: know the runtime closure (what you must copy to production) versus the build-time closure (what the sandbox needed), and measure both with nix-store / nix path-info before you ship.

Mental model

Nix records direct references by scanning output files for the 32-character hash of other store paths. If rg contains the string /nix/store/7r44p…-glibc-2.39, glibc is a runtime reference. gcc was used to compile rg but does not appear in the binary, so it is not in the runtime closure.

Term Command What you get
Direct references nix-store -q --references P Immediate edges out of P
Referrers nix-store -q --referrers P Paths that point at P
Runtime closure nix-store -q --requisites P P plus everything it needs to run
Deriver nix-store -q --deriver P The .drv that built P
build-time:  gcc, headers, cmake, fetchers
                │  (sandbox only)
                ▼
output:      rg  ──references──►  glibc, libgcc, …
                │
                ▼
runtime closure = rg + those references, transitively

Deploy the runtime closure. Do not deploy gcc because you compiled on this machine. result is a symlink to one path; the closure is that path plus requisites.

Worked examples

Case 1: Runtime closure of ripgrep

nix-store -q --requisites $(nix path-info nixpkgs#ripgrep)

Output (typical shape on 26.05):

/nix/store/7r44p12l4b72j9m9l09k96yghz02f8n7-glibc-2.39
/nix/store/…-libgcc-…
/nix/store/z1k947f6y788sfaivs931h26hsvb3506-ripgrep-14.1.1

A handful of paths. Not a compiler toolchain.

Case 2: Sizes, not just names

nix path-info -Shr nixpkgs#ripgrep
Flag Meaning
-S Size of this path
-h Human units
-r Closure (requisites)

The last line is the closure you would copy with nix copy. If that number is hundreds of megabytes for a “small” CLI, a wrapper script has pulled in Python or glib.

/nix/store/…-ripgrep-…     5.2M
…
narSize (closure)          32M

Numbers move with the 26.05 pin; the shape does not.

Case 3: Direct references versus the whole tree

echo 'references:'
nix-store -q --references $(nix path-info nixpkgs#ripgrep)
echo 'requisites:'
nix-store -q --requisites $(nix path-info nixpkgs#ripgrep)

References are one hop. Requisites are the transitive closure. For rg they are often almost the same. For a desktop environment they are not — nix path-info -Shr /run/current-system is the scary number.

Case 4: Why gcc is missing from runtime

out=$(nix path-info nixpkgs#hello)
drv=$(nix-store -q --deriver "$out")
echo "output: $out"
echo "deriver: $drv"
nix-store -q --references "$drv" | grep -E 'gcc|stdenv' | head
nix-store -q --references "$out"

The .drv references stdenv (gcc, binutils, …). The output of hello does not. Reference scanning dropped the toolchain. That is the entire point of separate build-time and runtime graphs.

Case 5: Copy what production needs

nix copy --to file://$PWD/desk-nar nixpkgs#ripgrep
ls desk-nar/nar | wc -l
nix path-info --store file://$PWD/desk-nar -r nixpkgs#ripgrep | grep gcc || echo 'no gcc in the NAR'

The NAR cache contains the runtime closure, not gcc. A colleague can nix copy --from file://$PWD/desk-nar and run rg without compiling.

On a live cache this is nix copy --to ssh://desk-cache or a Cachix push. Same graph.

If the runtime closure of desk-api contains gcc or python3 you did not mean to ship:

nix why-depends $(nix path-info nixpkgs#ripgrep) nixpkgs#gcc || echo 'gcc not in runtime (good)'

Cut the edge (wrapper, propagatedBuildInputs, shebang) rather than deleting gcc from the store by hand.

nix path-info -Sh nixpkgs#hello
nix path-info -Shr nixpkgs#hello

The first number is one path. The second is the closure you would nix copy. Write both down before you call a package “small.”

Interactive tree (optional extra):

nix run nixpkgs#nix-tree -- $(nix path-info nixpkgs#ripgrep)

Arrow keys walk references. nix-tree is a viewer, not a substitute for path-info -Shr in scripts.

The trap

The trap is shipping result by scp — one directory — and discovering the binary dies on the server because glibc is not there. result is a symlink to one path. The closure is that path plus requisites.

The other trap is reading --requisites on a .drv and panicking at the size. A .drv’s requisites include build-time inputs. Query the output path (nix path-info nixpkgs#ripgrep), not the deriver, when you mean “what runs.”

The boring rule

  • Runtime closure = --requisites of the output path.
  • Measure with nix path-info -Shr before you call a package “small.”
  • Deploy with nix copy (or a binary cache), never with a single scp of bin/.
  • Build-time tools belong in the .drv, not in production.
  • If a runtime closure contains a compiler, a wrapper or propagatedBuildInputs leaked. Fix the package.

Try this

  1. Compare nix path-info -Sh nixpkgs#hello (one path) with nix path-info -Shr nixpkgs#hello (closure). Write down both numbers.
  2. Run nix-store -q --graph $(nix path-info nixpkgs#hello) and pipe to head. You should see hello and glibc nodes.
  3. nix-store -q --referrers $(nix path-info nixpkgs#glibc | head -1) — many packages point at glibc. That is why it survives GC.
  4. Intentionally scp only the hello store directory to a tmp dir and try to run it. Then explain the interpreter error with readelf -l (the RPATH chapter).