closures and the garbage collector
transitive dependency graphs, gc roots, and what actually gets kept.
transitive dependency graphs, gc roots, and what actually gets kept.
references chain. curl references openssl. openssl references glibc. anything that needs curl transitively needs both.
the complete set of a path and everything it references, recursively, is its closure. --requisites gives it to you:
$ nix-store -qR /nix/store/xhp149abalfmj232yjhgbqaw281ba8np-curl-8.18.0
/nix/store/p98zvq4nb98krxcv7ss2zr1qngfmi0f5-gcc-14.3.0-libgcc
/nix/store/2a3izq4hffdd9r9gb2w6q2ibdc86kss6-xgcc-14.3.0-libgcc
/nix/store/3rkccxj7vi0p2a0d48c4a4z2vv2cni88-libunistring-1.4.1
/nix/store/hxcmad417fd8ql9ylx96xpak7da06yiv-libidn2-2.3.8
/nix/store/vr7ds8vwbl2fz7pr221d5y0f8n9a5wda-glibc-2.40-218
/nix/store/0p8b2lqk47fvxm9hc6c8mnln5l8x51q1-gcc-14.3.0-lib
/nix/store/3qkwgkbjisvm8z1g826bvqfg6j4cr35x-nghttp2-1.67.1-lib
/nix/store/592kyxjw9fnl255vcxkdpd8iaymg8y8l-keyutils-1.6.3-lib
/nix/store/8dj39rr9xp8qpl3myqj8i04a8pwhyl60-openssl-3.6.1
/nix/store/b3vi2i22167nhmrnl85ks3xpzl8bjj56-krb5-1.22.1-lib
/nix/store/7w67asczqr61prk2i4c2yrc4xcwi0vbj-publicsuffix-list-0-unstable-2025-12-28
/nix/store/b7wbagl6c1xr79r6hbcc7fznjjibc8mr-libpsl-0.21.5
/nix/store/xdxxfabbd8w0dadijsd8rkgvnhpn3rkf-zlib-1.3.1
/nix/store/chqzzvliv0mldn2n6b96aivcmhvc0hb8-libssh2-1.11.1
/nix/store/fwr62xmh06l8y8zfgc5m18pfap9b8az0-bash-5.3p3
/nix/store/gwy8kliqcqspz7r56y6hn2a0k28r5hak-zstd-1.5.7
/nix/store/ka7244lkykijn9k6p4c8a2acz8yz9pnd-brotli-1.1.0-lib
/nix/store/mjwqy9bka7zma90b6q6vg4lc51wzrwda-ngtcp2-1.17.0
/nix/store/pdp6hrs98pkfymhg7869pzggw2xizskc-nghttp3-1.12.0
/nix/store/xhp149abalfmj232yjhgbqaw281ba8np-curl-8.18.0
/nix/store/6jw31n10mjbjzn7ki2vyf6fs1cxif2qh-curl-8.18.0-bin
/nix/store/rbyqrgnyg5wf4zhc20783mjgr0pdis35-curl-8.18.0-man
22 paths, 59.6 MiB total. that is everything curl needs to run.
bash is in there too, even though curl doesn't link against it directly. curl-config (a shell script in curl's output) has a bash shebang, so the scanner picked up the path. nix follows the reference graph without filtering; if a hash is present in the output, that path is a dependency.
when you copy a package to a remote machine, its closure goes with it:
$ nix copy --to ssh://my-server nixpkgs#curl
nix queries what the remote store already has and sends only the missing paths. no separate dependency resolution step on the remote side.
$ nix path-info --recursive --size nixpkgs#curl \
| awk '{sum += $2} END {printf "%.1f MiB\n", sum/1024/1024}'
59.6 MiB
nixos works the same way at a larger scale. a system configuration produces a single derivation whose closure is the whole operating system: every service, every library, everything.
the store is append-only. over time it accumulates: old package versions, replaced system generations, build artifacts. the garbage collector removes paths that nothing needs anymore.
the question is what counts as "needed."
nix maintains explicit gc roots: symlinks in /nix/var/nix/gcroots/ pointing to store paths. anything reachable from a gc root through the reference graph is kept. everything else is eligible for deletion.
/nix/var/nix/gcroots/
├── auto/
│ └── gc-root-abc -> /nix/store/...-result
└── per-user/
└── karol -> /nix/var/nix/profiles/per-user/karol
your active profile lives at a store path. that path is a gc root, so its entire closure is kept.
a ./result symlink left by nix-build also registers as a root under auto/. as long as the symlink exists, the build output is kept.
toggle the roots below to see how reachability changes:
toggle roots to see what becomes unreachable
notice that glibc-2.40-218 stays in the kept column even when you disable the profile root, because it is also reachable through ./result -> my-project-1.0. removing both roots collects everything.
every time you install a package, nix creates a new profile generation. each generation is a distinct store path. old generations remain as gc roots until you delete them.
$ nix-env --list-generations
1 2024-01-15
2 2024-02-03
3 2025-01-12 (current)
$ nix-env --delete-generations old
switching to a new generation does not free the old one's packages. you have to delete the generation explicitly.
$ nix-store --gc
# or with the new cli
$ nix store gc
nix traverses the root graph, marks every reachable path, and deletes everything else:
finding garbage collector roots...
deleting '/nix/store/...-curl-7.88.0'
deleting '/nix/store/...-openssl-3.0.1'
1.2 GiB freed
to free more, delete old generations first:
$ nix-collect-garbage --delete-older-than 30d
$ nix-collect-garbage -d
$ nix-store -q --roots /nix/store/...-some-package
/nix/var/nix/gcroots/per-user/karol/profile -> /nix/store/.../profile
no output means the path is unreachable and will be deleted on the next gc run.
traditional package managers track what was installed by which command and run cleanup scripts on removal. nix does none of that. it marks everything reachable from a root and deletes the rest. no tracking, no scripts, no state to get wrong.
the nix language is where derivations come from. everything so far has been the runtime model: content-addressing, references, closures. the language is how you describe what to build.