closures and the garbage collector

transitive dependency graphs, gc roots, and what actually gets kept.

references chain. curl references openssl, which 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 path-info -r nixpkgs#curl
/nix/store/0i4lg2n828237gb8z97jriybl0q32iqi-curl-8.18.0-man
/nix/store/xwdzbdink53zjr7kjra8ywxjq61gnv57-libunistring-1.4.1
/nix/store/8ymbgi17j1s37kpr0wgn37bb1klbs3aa-libidn2-2.3.8
/nix/store/ih39s15a78n2kl7j3ilsd20v2cmp6241-xgcc-14.3.0-libgcc
/nix/store/km4g87jxsqxvcq344ncyb8h1i6f3cqxh-glibc-2.40-218
/nix/store/3j0jwcygamiza2n1ga69sdk6skaz5k3k-zlib-1.3.2
/nix/store/5vjb85kwn4q9gq7f9cl5q3if1w1ij75i-nghttp2-1.67.1-lib
/nix/store/6ph0zypyfc09fw6hlc1ygjvk2hv4j9vd-bash-5.3p3
/nix/store/j1wa34bbz07lwhgqdzwnj2nr2w4pl7zr-openssl-3.6.1
/nix/store/xq4k5v0lj8jp9xwwcq8cy9y9p2vgr8pl-keyutils-1.6.3-lib
/nix/store/a7k3idqxhcm56k37qxaza3kq37rk7gl8-krb5-1.22.1-lib
/nix/store/bi7cpxd5mwk66220j4nzhvg0k0prj8i0-nghttp3-1.12.0
/nix/store/hjqsazy6dc8h1dfvkjjxf1g8rlm4skxc-gcc-14.3.0-libgcc
/nix/store/alrbhz7im0w0jdwcfdgcfk7pxhkl1fzj-gcc-14.3.0-lib
/nix/store/bnnzrqcdix96yliwz2l2a4vphhg30nzc-zstd-1.5.7
/nix/store/f8pk7vnhs9kk45a32ng21m13bw96j621-libssh2-1.11.1
/nix/store/xwly0k0zqz0pvbgz4apyq0p4zzm9yb88-publicsuffix-list-0-unstable-2026-01-25
/nix/store/gljldwi3jdbmrdg4186415pz2n42hhlg-libpsl-0.21.5
/nix/store/rsmb0b09rjzrjhng3wcqcx7bab9kjjik-brotli-1.1.0-lib
/nix/store/yr0ynadyy8dnhvqs1ka90gglqhqkkrif-ngtcp2-1.17.0
/nix/store/824ww67sqwx0jg9ls3n01i9nrbsp2lx1-curl-8.18.0
/nix/store/wbk2dz06hd8zzam4j1wxyd7va48dqdbm-curl-8.18.0-bin

22 paths, 59.6 MiB total. that is everything curl needs to run.

bash is in there because wcurl (a shell script in the bin output) has a bash shebang. the scanner found the hash in the script. nix follows the reference graph without filtering; if a hash is present in the output, that path is a dependency.

closures and deployment

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 garbage collector

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

gc roots

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:

gc roots
kept
profile-v3
via ~/.nix-profile
curl-8.18.0
via ~/.nix-profile
openssl-3.6.1
via ~/.nix-profile
zlib-1.3.2
via ~/.nix-profile
glibc-2.40-218
via ~/.nix-profile, ./result
my-project-1.0
via ./result
collected
curl-7.88.0
openssl-3.0.1

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.

profile generations

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.

running the collector

$ 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

checking why a path is kept

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

how gc works

traditional package managers track what was installed by which command and run cleanup scripts on removal. nix does not use that kind of install bookkeeping. it marks everything reachable from a root and deletes the rest. no cleanup scripts, no partial uninstall state to get wrong.

what's next

the nix language is where derivations come from. everything so far has been the store model: hash-derived paths, references, closures. the language is how you describe what to build.