Konubinix' opinionated web of thoughts

Nixos Store Analysis and Cleanup

Fleeting

NixOS keeps every store path alive as long as something references it — a generation, a GC root, a result symlink, even a running process. Over time the /nix/store grows silently. This note walks through understanding what takes space, finding what can be freed, and cleaning it up.

How big is my store?

Start here. This gives the total disk usage of the nix store.

du -sh /nix/store /nix/var
35G	/nix/store
77M	/nix/var

Almost everything lives in /nix/store. The /nix/var overhead is negligible.

Understanding what takes space

The store is shared: many packages depend on the same libraries, so closure sizes overlap. A 7GB system closure + a 7GB home-manager closure might only use 10GB on disk because glibc, Qt, etc. are counted once.

List all GC roots

Everything alive in the store is reachable from a GC root. This shows what’s keeping paths alive:

nix-store --gc --print-roots 2>&1 | grep -v '/proc/'
/home/sam/.cache/nix/flake-registry.json -> /nix/store/pnaz66ik1q8alabvzm895x2i66kcnvbx-flake-registry.json
/home/sam/.local/state/home-manager/gcroots/current-home -> /nix/store/fl4p4ygwbsfrgd52xp81kynqa1kfha7b-home-manager-generation
/home/sam/.local/state/nix/profiles/home-manager-105-link -> /nix/store/60z853dchkj350s3vlq1d7pqivr8g07v-home-manager-generation
/home/sam/.local/state/nix/profiles/home-manager-106-link -> /nix/store/xq3pkycr0nc1p4gnxdy7042dbw5nfnhz-home-manager-generation
/home/sam/.local/state/nix/profiles/home-manager-107-link -> /nix/store/fl4p4ygwbsfrgd52xp81kynqa1kfha7b-home-manager-generation
/home/sam/.local/state/nix/profiles/profile-434-link -> /nix/store/pxz6z7ngmclnggc8girwyy244pq2d14v-profile
/home/sam/prog/keyban/dap/.devbox/nix/profile/default-5-link -> /nix/store/4aga4hk2cangqgnla1xjrwhm05lm3cqn-profile
/nix/var/nix/profiles/per-user/root/channels-1-link -> /nix/store/3rx5srpc6jlmhaym7qzj80123szcvqnj-nixos-25.11.6820.c581273b8d5b
/nix/var/nix/profiles/system-83-link -> /nix/store/25v0g8cfddwqf6f73acadangijf5jax1-nixos-system-konix-26.05.20260303.8c809a1
/nix/var/nix/profiles/system-84-link -> /nix/store/14x04k80gdygzh03laf7qgi1laqlsgs7-nixos-system-konix-26.05.20260303.8c809a1
/run/booted-system -> /nix/store/14x04k80gdygzh03laf7qgi1laqlsgs7-nixos-system-konix-26.05.20260303.8c809a1
/run/current-system -> /nix/store/14x04k80gdygzh03laf7qgi1laqlsgs7-nixos-system-konix-26.05.20260303.8c809a1

The /proc/ entries are from running processes — they go away on reboot or when the process exits. The interesting roots are:

  • ~/.local/state/nix/profiles/profile-*-link — user profile generations from nix profile install. Often the biggest root because it accumulates every package you’ve ever installed. This profile is populated on-demand by the hardliases system (~/prog/devel/bin/hardliases/): each hardlias is a wrapper script that calls nix profile add on first run. So the profile grows over time as you use more commands. Cleaning old generations with nix profile wipe-history is safe — the hardliases will reinstall on next use. Removing a package from the profile entirely is also safe for the same reason, but it will trigger a rebuild on next invocation.
  • /nix/var/nix/profiles/system-*-link — NixOS system generations. Each one is a full bootable system closure. Clean with sudo nix-collect-garbage --delete-older-than 7d.
  • result symlinks in project directories — leftover nix build outputs. Each one registers a GC root in /nix/var/nix/gcroots/auto/ that persists even if the symlink is deleted. Can hold large closures alive.
  • ~/.local/state/nix/profiles/home-manager-*-link — Home Manager generations. Old ones keep their full closure alive. Clean with home-manager expire-generations.
  • /tmp/ symlinks — same as result, but in /tmp. The symlinks themselves disappear on reboot, but the GC root entries persist. nix-collect-garbage detects and removes these stale roots automatically.
  • .devbox/nix/profile/ — devbox project environments. Each project with a devbox.json gets its own nix profile.
  • /nix/var/nix/profiles/per-user/root/channels-*-link — the root user’s channel (e.g. the nixpkgs snapshot used by sudo nixos-rebuild). Usually a few hundred MB. Cleaned by sudo nix-collect-garbage -d.
  • ~/.local/state/home-manager/gcroots/current-home — symlink to the active Home Manager generation. Managed by Home Manager, don’t touch it.
  • /run/current-system — points to the currently active system generation (may differ from booted-system after nixos-rebuild switch without rebooting). Managed by NixOS, don’t touch it.
  • /run/booted-system — points to the system generation that was booted. Set at boot, read-only. Goes away on reboot.
  • ~/.cache/nix/flake-registry.json — cached copy of the global flake registry that maps shorthand names (like nixpkgs) to URLs. Tiny, harmless.

Closure sizes per root

A closure is a package plus everything it depends on. This shows how big each major root is (with overlap — they don’t sum to the total). In particular, the user profile includes home-manager-path as one of its entries, so its closure contains the entire Home Manager closure plus all other packages installed via nix profile install.

echo "=== NixOS system ==="
for p in /nix/var/nix/profiles/system-*-link; do
  size=$(nix path-info --closure-size "$p" 2>/dev/null | awk '{printf "%.1f GB", $2/1024/1024/1024}')
  echo "  $(basename $p) -> $size"
done

echo ""
echo "=== Home Manager ==="
for p in ~/.local/state/nix/profiles/home-manager-*-link; do
  size=$(nix path-info --closure-size "$p" 2>/dev/null | awk '{printf "%.1f GB", $2/1024/1024/1024}')
  echo "  $(basename $p) -> $size"
done

echo ""
echo "=== User profile ==="
if [ -e ~/.local/state/nix/profiles/profile ]; then
  size=$(nix path-info --closure-size ~/.local/state/nix/profiles/profile 2>/dev/null | awk '{printf "%.1f GB", $2/1024/1024/1024}')
  echo "  profile -> $size"
fi
=== NixOS system ===
  system-83-link -> 6.8 GB
  system-84-link -> 6.8 GB

=== Home Manager ===
  home-manager-105-link -> 7.3 GB
  home-manager-106-link -> 7.3 GB
  home-manager-107-link -> 7.3 GB

=== User profile ===
  profile -> 29.3 GB

Zoom in

Nixos

These are the individual store paths (not closures) that take the most space inside the NixOS system generation. This helps understand what the system itself pulls in — firmware, mesa, kernel modules, docker, etc.

nix path-info --size -r /nix/var/nix/profiles/system | \
  awk '{sz=$2/1024/1024; if (sz>=1024) printf "%.1f GB %s\n", sz/1024, $1; else printf "%.0f MB %s\n", sz, $1}' | sort -t' ' -k1 -rn | head -100
734 MB /nix/store/g8fv0vl1zq3nm823jm93n95y3dafp7b1-linux-firmware-20260221-zstd
645 MB /nix/store/y0n2rsgi3lfdsgvh2rdkglnb2c07a4gs-source
540 MB /nix/store/s6v0k4cvllyp5cy03ngxz0snj7z23ljm-llvm-21.1.8-lib
528 MB /nix/store/nax6synifbl6syx1l2sxjrf1xh1qprqb-llvm-21.1.8-lib
260 MB /nix/store/7bcamlsr1y22gsi3br0dvw0slw5k7qqz-mesa-26.0.1
252 MB /nix/store/6rcij4a3pcjm94r0k0i3vil6qyhaamdy-mesa-26.0.1
222 MB /nix/store/0qlmqvyckf8zyx03n00azgznnkzgi5xw-glibc-locales-2.42-51
187 MB /nix/store/affmc6lhad8f6q3iaa3iydcdjwr8lwgp-source
165 MB /nix/store/4z51xyah9h8h3al1wclvgy6cb04vq0vl-qtdeclarative-6.10.2
133 MB /nix/store/6wm58zyrnz70240xywc0l9ml18rf03qg-linux-6.18.15-modules
107 MB /nix/store/m1fw8l8y9ycxh5dzispbb7cwl6rra14l-python3-3.13.12
99 MB /nix/store/iarcrhvmxrk4hfqqhy9di2kagb2rjwji-moby-29.2.1
98 MB /nix/store/9g44xzv8bxq3n9pg3h7wjjcbqg70gy4l-docker-containerd-29.2.1
86 MB /nix/store/rkh0rc2nhdy7iian5vdhbsb8fmy0x21x-docker-buildx-0.31.1
83 MB /nix/store/w4q31b93w262q2b75ri3jc7m3xd4i31h-qtbase-6.10.2
74 MB /nix/store/z2hrkj6a6vcbyf90924x2chhmlgwqmpq-python3-3.13.12
61 MB /nix/store/crabwpx7qyz7rysfdvsfg4dclk811q9d-xscreensaver-6.13
61 MB /nix/store/1m0z4ixir10fg7ba7g131js04bxgv265-cmake-4.1.2
60 MB /nix/store/73f5hxj105ny4q0phlv616lwic4sv6n4-flite-2.2-lib
59 MB /nix/store/r042cd7600rhl130idnmwxqyjc9126s4-systemd-259
56 MB /nix/store/wxyn8d3m8g4fnn6xazinjwhzhzdg6wib-systemd-259
55 MB /nix/store/4pfrhja4j3j5g4iwssn9w43sgihdf4wl-perl-5.42.0
51 MB /nix/store/swibh67af55w3pxsjlksyxfihamf9i2z-git-minimal-2.53.0
49 MB /nix/store/sp3pq0in1ca25hyr0ffigvx2g2dhqs91-noto-fonts-2026.02.01
49 MB /nix/store/fy54dwdr2wxf1phq3sgvrdyqdimzb48p-ghostscript-with-X-10.06.0
41 MB /nix/store/q43d6pbb9qxyhj6hq4id5mdg6gfrz6gv-gtk+3-3.24.51
38 MB /nix/store/wcmcai1w6ww72hflq95k61fsx7kz0d2q-icu4c-76.1
38 MB /nix/store/3zlq53qsimf0y525qxycjha8px0q7ygj-icu4c-76.1
34 MB /nix/store/9jk9p6679rz3lsc0mdnyf8h8yq4y8cc6-docker-29.2.1
33 MB /nix/store/ip1zll830af00llffx8s711dhvr51vmq-ffmpeg-headless-8.0.1-lib
33 MB /nix/store/8x2msxrm9yb8ycz9pi1pi8wz5zs9gy64-ffmpeg-8.0.1-lib
32 MB /nix/store/wd2988s9rndikj1l849l350zyv7lzz6h-freepats-20060219
29 MB /nix/store/zls8yqy7drgh35gp3nrm27ljw89n6d8p-pipewire-1.4.10-doc
29 MB /nix/store/r5ifa1m0sqa6ny76jgglds7gr540837m-gtk4-4.20.3
29 MB /nix/store/n2115sg4klccw8a2rq50ndffi7sizmrg-speech-dispatcher-0.12.1
29 MB /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51
29 MB /nix/store/alal0jcsb5gyg9k6x979743k2d0lxamh-ffmpeg-headless-8.0.1-lib
28 MB /nix/store/9l26qshd1kimgay1l8mqab8yb50cfdd7-glibc-2.42-51
27 MB /nix/store/q2d8xphgqlvvdzlnb3pciryjxljq6c09-docker-compose-5.0.2
27 MB /nix/store/pik5qhm6hzigjffh1aqarjc9gd351281-nixos-manual-html
25 MB /nix/store/1whg68gwyb9q9jjasqvjigqs3jma9q7y-rootlesskit-2.3.6
24 MB /nix/store/drba08mg0sbqrsi8phkjbywkj3kn0fac-x265-4.1
23 MB /nix/store/7cqhfagsrlp3h6pv273ihfd1k694gsa4-iso-codes-4.20.1
23 MB /nix/store/5x3b9ijj1bb2fh8zqskl91rn41lh5l0x-iso-codes-4.20.1
22 MB /nix/store/8rw5ajghj5pjqjcx0armbbnbq3cdn270-linux-6.18.15
21 MB /nix/store/nj9xirbbrcj2mfvb6brd9l1hrygyrkiw-nix-manual-2.31.3
20 MB /nix/store/3nb4k9pzmwxq4gxqprhnb37np7yqjwqc-networkmanager-1.56.0
19 MB /nix/store/md592gfars4m9madyrpj9yrq5jhckgjf-gettext-0.26
19 MB /nix/store/g5kvz35643fp9yhxnkwcnwkkrhyj6d71-systemd-minimal-259
19 MB /nix/store/cg2s5xhv346wyy9lfxk9m53g9rra9xhm-espeak-ng-1.52.0.1-unstable-2025-09-09
19 MB /nix/store/5q8y4gqcrp6dnwxkkcqsl4xp1nd39q0b-networkmanager-1.56.0
19 MB /nix/store/356k9ng1i14ssg5pizc66lhywr7rdbsv-systemd-minimal-259
19 MB /nix/store/1saivc2rj6rn1ykvm4rdzlddg41a2wqa-steam-unwrapped-1.0.0.85
17 MB /nix/store/vpq2b3g9rydknxbyxwrdz45vz6kk3fqv-unifont-16.0.03
16 MB /nix/store/x6adcywj0fgd4air162d1pphzh8cjvqn-gst-plugins-bad-1.26.5
16 MB /nix/store/fcqhn9m2sih0rmhw1j1ffxrkl8vw7248-pipewire-1.4.10
15 MB /nix/store/rvvxz8y9smssjbh8x9kk0zdy2iv8ywy5-glib-2.86.3
15 MB /nix/store/plsmwx39r785b1dyblqp4lv81gbdpy4c-qttranslations-6.10.2
15 MB /nix/store/6r0krhrml6hz987s40kbqczq5923i7ix-librsvg-2.61.3
15 MB /nix/store/1qm74vf93ik1xjrr9kl6qvjrklljlcqh-glib-2.86.3
15 MB /nix/store/1aqyzz6pgi49sb8lngypa9ddh5gv993q-pipewire-1.4.10
14 MB /nix/store/y6mlyr480fgdd5bgpcfwwraa6vivhmwj-gnupg-2.4.9
14 MB /nix/store/wrwzn9h21jzg24dilb15wsh0010idkk6-boost-1.89.0
14 MB /nix/store/m04yk1kq9qqigl8c260qrp3asdz1wnw1-modemmanager-1.24.2
14 MB /nix/store/6fsxn74jfpd2ffk454qsjyk8jzcqzm4w-texinfo-interactive-7.2
13 MB /nix/store/q3f55hdd85088xx3sx98x9vmac13gdjk-libajantv2-17.5.0
13 MB /nix/store/h7whg7naplaib6ykafbqfybl1w748x64-docker-runc-29.2.1
13 MB /nix/store/db6sbfmg9zb4g1v77kwpd4waq0ij55nd-util-linux-2.41.3-lib
13 MB /nix/store/8aq1xr8m40x2mrlnmm06mzzjmjv9fvx0-hwdb.bin
13 MB /nix/store/7kxx839gkx9vdyk9wfzq14l66bhdmjsd-wireplumber-0.5.13-doc
13 MB /nix/store/2sfycqdy94jdin4qq04yws1wgq10yz6l-mbedtls-3.6.5
13 MB /nix/store/15hqn31hgllprki8p6l67whix262nn3d-modemmanager-1.24.2
12 MB /nix/store/qsh3yma0sycqh4fcbi48yxv5b1ja7sn9-initrd-linux-6.18.15
12 MB /nix/store/m80scad7b9qzkc0cdk2i4d640ypphbmd-coreutils-full-9.10
12 MB /nix/store/a87fr6776d06pa1m2lx393swn0l4sgla-mbedtls-3.6.5
11 MB /nix/store/aji4a8j115inll9j57w91489ycxaq1b7-sof-firmware-2025.05.1-zstd
11 MB /nix/store/a86d34l78hd1cm0dg3fw09c0qqr245dw-xorg-server-21.1.21
10 MB /nix/store/ypxd9dldr1j8lbg7a5ws42gqa2kizgyh-bluez-5.84
10 MB /nix/store/rg4a85a1rnnagbvapkrp3xfdvg7m140w-noto-fonts-color-emoji-2.051
10 MB /nix/store/p8zzznrm6q1mrlwy6i5z3zr4f87cdc8w-hwdata-0.404
10 MB /nix/store/m3b3fnv33pzhhbadwg3qdqpcjvlg83jn-hwdata-0.404
10 MB /nix/store/ihpdbhy4rfxaixiamyb588zfc3vj19al-gcc-15.2.0-lib
10 MB /nix/store/hndbrrk1gggj5lkd8d9x7q0s7gk94z63-steam-1.0.0.85-fhsenv-rootfs
10 MB /nix/store/h5c0q8a7c61zqgnhqq3psk3g76ajpldh-steam-run-1.0.0.85-fhsenv-rootfs
10 MB /nix/store/7l9d49jd3c3a9hyqx70frrn616n3il4i-xkeyboard-config-2.46
10 MB /nix/store/6cms9f4w6pxp7gkxf464zg031w27pnag-freefont-ttf-20120503
10 MB /nix/store/4g07cwy57p5214szx7fmlkijpb6p6r47-bluez-5.84
10 MB /nix/store/2r1nbfavp94gl5q660vg3x0s4a5vyl6s-libjxl-0.11.2
9 MB /nix/store/vlhrk50xg0lmivk049q8yygbsm86l6m0-openssh-10.2p1
9 MB /nix/store/q8zb95f4m2aahbdi6jl7wh3cjwc4p855-cups-2.4.16
9 MB /nix/store/p96a7p297gmia8zcy4i72qd45wzw8lh6-openssl-3.6.1
9 MB /nix/store/kgf6zn39f6mxg0l3hjx29rpg7a0y3viv-gst-plugins-base-1.26.5
9 MB /nix/store/ilk5qzvkadnj7lx58hfinfvl7jmhriq6-util-linux-2.41.3-bin
9 MB /nix/store/dml7jig0fh35chx530ga4icf68j4qayf-dejavu-fonts-2.37
9 MB /nix/store/c1yv698q09l7lay400v0zz9ajicmf65b-libreelec-dvb-firmware-1.5.0-zstd
9 MB /nix/store/abp49ljkaq0hnzfk0f016rzsrx4rhkx3-gst-plugins-base-1.26.5
9 MB /nix/store/7v308am31q0n3zk2361gqi5pv3swzrnw-groff-1.23.0
9 MB /nix/store/7nvb5clr8li75nn09gjqihy5mbx3g86i-libvpx-1.15.2
9 MB /nix/store/2y2faybcp9a3qgnivfmg4iznqvjcq6pa-zenity-4.2.1
8 MB /nix/store/ycv0bdf8bg06p8w6hhsk409c2q91clqb-svt-av1-3.1.2

The path shown is the current system generation. If you have multiple generations, replace system with system-NN-link to compare.

You can also visualize the dependency graph (see annex):

focusing into ffmpeg

That’s strange, why does ffmpeg take “only” 28 MB but 247 MB in cumulative size ?

Let’s focus on it, showing even the small nodes in the path.

user profile

The user profile (from nix profile install) is often the biggest root because it accumulates packages over time. Most of these packages come from the hardliases system (~/prog/devel/bin/hardliases/) which lazily installs them on first use. The home-manager-path entry is the closure of the entire Home Manager environment (all packages from the HM config), not an individual package. This breaks it down:

import json
import subprocess

if isinstance(profile_json, str):
    data = json.loads(profile_json)
else:
    data = json.loads("\n".join(str(x) for x in profile_json))
sizes = []
for name, elem in data.get("elements", {}).items():
    for p in elem.get("storePaths", []):
        r = subprocess.run(
            ["nix", "path-info", "--closure-size", p],
            capture_output=True, text=True,
        )
        if r.returncode == 0:
            parts = r.stdout.strip().split()
            if len(parts) == 2:
                sizes.append((name, int(parts[1])))
sizes.sort(key=lambda x: -x[1])
for name, s in sizes:
    gb = s / 1024 / 1024 / 1024
    if gb >= 0.1:
        print(f"  {name} -> {gb:.1f} GB")
    else:
        print(f"  {name} -> {s // 1024 // 1024} MB")
flakes/android -> 8.3 GB
home-manager-path -> 7.3 GB
whisper-ctranslate2 -> 2.8 GB
blender -> 2.5 GB
mermaid-cli -> 2.0 GB
openboard -> 1.8 GB
cargo -> 1.6 GB
firefox -> 1.5 GB
handbrake -> 1.4 GB
vlc -> 1.3 GB
flakes/scrcpy -> 1.1 GB
openjdk -> 0.8 GB
libextractor -> 0.8 GB
bitwarden-cli -> 0.7 GB
deskflow -> 0.7 GB
apksigner -> 0.6 GB
apktool -> 0.6 GB
pdfarranger -> 0.6 GB
zbar -> 0.6 GB
zathura -> 0.5 GB
keepassxc -> 0.5 GB
fstl -> 0.4 GB
seahorse -> 0.4 GB
vault -> 0.4 GB
awscli2 -> 0.3 GB
feh -> 0.3 GB
pre-commit -> 0.3 GB
loudgain -> 0.3 GB
chromaprint -> 0.3 GB
argocd -> 0.3 GB
pdftotext -> 0.2 GB
typescript-language-server -> 0.2 GB
typescript -> 0.2 GB
flakes/colout -> 0.2 GB
pgcli -> 0.2 GB
pnpm -> 0.2 GB
eslint -> 0.2 GB
borgbackup -> 0.2 GB
msmtp -> 0.2 GB
flakes/renault-api -> 0.2 GB
httpie -> 0.2 GB
imagemagick -> 0.2 GB
consul -> 0.2 GB
flakes/rotate-backups -> 0.2 GB
deno -> 0.2 GB
mpremote -> 0.2 GB
w3m -> 0.2 GB
xvfb-run -> 0.1 GB
postgresql_16 -> 0.1 GB
graphviz -> 0.1 GB
poppler-utils -> 0.1 GB
kubo -> 0.1 GB
hugo -> 0.1 GB
ntfy-sh -> 0.1 GB
cups -> 0.1 GB
sox -> 0.1 GB
exiftool -> 0.1 GB
ledger -> 0.1 GB
sshfs -> 0.1 GB
uv -> 0.1 GB
nssTools -> 100 MB
util-linux -> 90 MB
gh -> 86 MB
ruff -> 84 MB
x11vnc -> 68 MB
nixfmt -> 68 MB
websocat -> 67 MB
dig -> 64 MB
ty -> 63 MB
devbox -> 62 MB
dagger -> 60 MB
kubectl -> 60 MB
libsecret -> 54 MB
terraform-ls -> 50 MB
fd -> 50 MB
stylua -> 50 MB
wget -> 47 MB
socat -> 46 MB
ncdu -> 46 MB
jless -> 46 MB
libfaketime -> 45 MB
flakes/oama -> 45 MB
crane -> 45 MB
lighttpd -> 43 MB
flakes/hld -> 42 MB
bkt -> 42 MB
openssl -> 41 MB
inotify-tools -> 41 MB
xclip -> 39 MB
flakes/phasher -> 39 MB
earthly -> 35 MB
xsel -> 35 MB
mpc -> 33 MB
sqlite -> 33 MB
dos2unix -> 33 MB
keyutils -> 33 MB
qrencode -> 31 MB
tree -> 31 MB
colorized-logs -> 31 MB
pstree -> 31 MB
openssl -> 3 MB
postgresql_16 -> 0 MB
util-linux -> 0 MB
kubectl -> 0 MB
cups -> 0 MB
sox -> 0 MB
keyutils -> 0 MB
borgbackup -> 0 MB
feh -> 0 MB
zbar -> 0 MB
sqlite -> 0 MB
qrencode -> 0 MB

You can also visualize the dependency graph (see annex):

Home Manager

Same idea for Home Manager. This closure often includes heavy packages like chromium, emacs, qtwebengine, gcc (for native-comp), etc. The all entry at the top is the aggregated derivation for the entire Home Manager profile, not an individual package. Note: --size reports each path’s own size (just the symlink farm), while the per-package breakdown above uses --closure-size which includes all transitive dependencies — hence home-manager-path appears much larger there.

nix path-info --size -r ~/.local/state/nix/profiles/home-manager | \
  awk '{sz=$2/1024/1024; if (sz>=1024) printf "%.1f GB %s\n", sz/1024, $1; else printf "%.0f MB %s\n", sz, $1}' | sort -t' ' -k1 -rn | head -20
1015 MB /nix/store/1jncnixlflajcb85daq66kggsy2hb78f-all
649 MB /nix/store/dp52zh2z75dbd4nmy1wxc17msyygy2gm-chromium-unwrapped-145.0.7632.116
434 MB /nix/store/fa965xaq074a2c0v4d2ssrg02y5syl9v-qtwebengine-6.10.2
326 MB /nix/store/nqzrva0994k1xgh86kavhi5qcg54ixiq-emacs-30.2
264 MB /nix/store/sca0pf46jmxva40qahkcwys5c1lvk6n2-gcc-15.2.0
222 MB /nix/store/0qlmqvyckf8zyx03n00azgznnkzgi5xw-glibc-locales-2.42-51
181 MB /nix/store/7hgr71y4mchf0h4w4i36j49lhvzl4nc2-docker-scout-1.20.2
165 MB /nix/store/4z51xyah9h8h3al1wclvgy6cb04vq0vl-qtdeclarative-6.10.2
141 MB /nix/store/qx6qwzhfxqskg87zl9igij2nbk0kclmk-deno-2.6.10
139 MB /nix/store/6n4hzs2swrk7hy8v36hs118n33sw7qz4-libgccjit-15.2.0
107 MB /nix/store/m1fw8l8y9ycxh5dzispbb7cwl6rra14l-python3-3.13.12
99 MB /nix/store/iarcrhvmxrk4hfqqhy9di2kagb2rjwji-moby-29.2.1
98 MB /nix/store/9g44xzv8bxq3n9pg3h7wjjcbqg70gy4l-docker-containerd-29.2.1
86 MB /nix/store/rkh0rc2nhdy7iian5vdhbsb8fmy0x21x-docker-buildx-0.31.1
85 MB /nix/store/q62chy9hy3wfyb63q166dg1xldl506m1-index-x86_64-linux
84 MB /nix/store/yricn9c0wsv39r324dm96bsyfqvsdp3p-samba-4.22.7
83 MB /nix/store/w4q31b93w262q2b75ri3jc7m3xd4i31h-qtbase-6.10.2
80 MB /nix/store/h4apbnz8p5fmjwcmzlaxcjm5vr70d97x-nodejs-slim-24.13.0
76 MB /nix/store/gsykwaghk72cm2wwfcxpznr0i3yp9020-opencv-4.13.0
61 MB /nix/store/crabwpx7qyz7rysfdvsfg4dclk811q9d-xscreensaver-6.13

You can also visualize the dependency graph (see annex):

Find dangling result symlinks

nix build creates result symlinks that register as GC roots. They accumulate in project directories and keep old builds alive. Note: some hardliases use custom flakes (e.g. scrcpy builds from ~/prog/devel/flakes/scrcpy) — if you run nix build manually in those flake directories, the result symlink will linger there too.

nix-store --gc --print-roots 2>&1 | grep -v '/proc/' | grep 'result'

To remove them (review the list first!):

roots=$(nix-store --gc --print-roots 2>&1 | grep -v '/proc/' | grep 'result' | awk '{print $1}')
for r in $roots; do
  if [ "$DRY_RUN" = "yes" ]; then
    echo "[dry run] would remove: $r"
  else
    rm -v "$r"
  fi
done

Set DRY_RUN to "no" in the header to actually delete them.

Find /tmp GC roots

Same problem as result symlinks, but in /tmp. The symlinks themselves may be gone after reboot, but the GC root entries in /nix/var/nix/gcroots/auto/ persist. nix-collect-garbage cleans stale roots automatically, but it helps to know they’re there:

nix-store --gc --print-roots 2>&1 | grep -v '/proc/' | grep '/tmp/'

Check for reclaimable space (dry run)

Before actually collecting, see how much would be freed:

nix-collect-garbage --dry-run 2>&1 | tail -3

If this shows nothing, the store is already clean — all paths are live.

Cleaning up

Remove old NixOS generations

List current generations:

nix-env --list-generations --profile /nix/var/nix/profiles/system
83   2026-03-25 11:15:33
84   2026-03-29 11:51:21   (current)

Delete all but the current one:

nix-collect-garbage -d

Or keep the last 7 days:

nix-collect-garbage --delete-older-than 7d

Remove old Home Manager generations

List current generations:

home-manager generations

Expire old ones (keep last 7 days):

home-manager expire-generations "-7 days"

Remove old user profile generations

Every time a hardlias triggers nix profile add, a new generation is created. Over time this accumulates many generations, each holding a full closure. Wiping history is safe — it only removes old generations, not the current one. All installed packages remain available.

nix profile wipe-history --profile ~/.local/state/nix/profiles/profile

Or keep recent ones:

nix profile wipe-history --profile ~/.local/state/nix/profiles/profile --older-than 7d

You can also remove individual packages to save space. The hardlias wrapper will reinstall it transparently the next time you run the command (at the cost of a one-time rebuild):

nix profile remove --profile ~/.local/state/nix/profiles/profile "$PKG"

Garbage collect the store

This removes all store paths not reachable from any GC root (including stale roots pointing to deleted symlinks):

nix-collect-garbage

For a more aggressive clean (also removes old generations from all profiles system-wide — use --delete-older-than variant above if you want more control):

nix-collect-garbage -d

Rebuild boot entries

After removing old system generations, the boot loader (systemd-boot) may still have stale entries. The generation files in /nix/var/nix/profiles/ are gone, but the entries in /boot/loader/entries/ are not cleaned automatically by garbage collection. Regenerate them:

boot_entries=$(ls /boot/loader/entries/ | wc -l)
generations=$(nix-env --list-generations --profile /nix/var/nix/profiles/system | wc -l)
echo "boot entries: $boot_entries"
echo "system generations: $generations"
if [ "$boot_entries" -gt "$generations" ]; then
  echo "=> STALE: $((boot_entries - generations)) extra boot entries to clean"
else
  echo "=> OK"
fi
boot entries: 2
system generations: 2
=> OK

If stale, rebuild:

/run/current-system/bin/switch-to-configuration boot

This regenerates boot entries to match only the existing generations. Verify:

ls /boot/loader/entries/ | wc -l

The change takes effect on next reboot.

Which boot loader am I using?

if [ -d /boot/loader/entries ]; then
  echo "systemd-boot"
elif [ -d /boot/grub ]; then
  echo "GRUB"
else
  echo "unknown — check /boot/"
fi
systemd-boot

GRUB variant

If using GRUB instead of systemd-boot, the rebuild command is different:

nixos-rebuild boot

Verify

After cleanup, check the final size and confirm nothing is left to collect:

du -sh /nix
echo "---"
nix-collect-garbage --dry-run 2>&1 | tail -3

Annex

Dependency graph code

A visual graph of the dependency tree makes it easy to spot which paths pull in the most weight. Each node shows the package name and its own size; edges show “depends on”. Only paths above a configurable threshold are included to keep the graph readable.

Change root to the root you want to explore (a GC root, a profile, or any store path). Change minmb to filter out small nodes. Set focus to a package name to restrict the graph to that package’s closure.

The minmb threshold applies differently depending on the mode:

  • Without focus: filters by own size (narSize). This keeps the graph small and readable on large closures like the full system.
  • With focus: filters by closure size. When zooming into a single package, you want to see the full dependency chain — including small intermediary packages that pull in heavy transitive dependencies.

The graph is color-coded:

  • Red: ≥500 MB
  • Orange: ≥200 MB
  • Yellow: ≥100 MB
  • Green: <100 MB (but above the threshold)

The main entry point assembles all the pieces:

import json
import subprocess
import re
import os

min_bytes = int(minmb) * 1024 * 1024
store_path = os.path.expanduser(root)

r = subprocess.run(
    ["nix", "path-info", "-r", "--json", "--closure-size", store_path],
    capture_output=True, text=True,
)
if r.returncode != 0:
    raise RuntimeError(f"nix path-info failed: {r.stderr}")
raw = json.loads(r.stdout)

# nix path-info --json returns a dict keyed by store path
if isinstance(raw, list):
    info = {p["path"]: p for p in raw}
else:
    info = {path: data for path, data in raw.items()}

def closure_size(path):
    return info[path].get("closureSize", 0)
if focus:
    focus_path = [p for p in info if re.search(re.escape(focus), p)]
    if not focus_path:
        raise RuntimeError(f"no store path matching '{focus}'")
    focus_path = focus_path[0]
    relevant = set()
    stack = [focus_path]
    while stack:
        p = stack.pop()
        if p in relevant or p not in info:
            continue
        relevant.add(p)
        for ref in info[p].get("references", []):
            if ref not in relevant:
                stack.append(ref)
    info = {p: info[p] for p in relevant if p in info}
if focus:
    big = {path for path in info if closure_size(path) >= min_bytes}
else:
    big = {path for path in info if info[path].get("narSize", 0) >= min_bytes}
def fmt_size(size):
    if size >= 1024 * 1024 * 1024:
        return f"{size / 1024 / 1024 / 1024:.1f} GB"
    elif size >= 1024 * 1024:
        return f"{size // (1024 * 1024)} MB"
    else:
        return f"{size // 1024} KB"

def label(path):
    hash_prefix = re.search(r"^/nix/store/([a-z0-9]{8})", path).group(1)
    name = re.sub(r"^/nix/store/[a-z0-9]+-", "", path)
    own = info[path].get("narSize", 0)
    clos = closure_size(path)
    return f"{name} ({hash_prefix})\\n{fmt_size(own)} (closure: {fmt_size(clos)})"

def color(path):
    size = info[path].get("narSize", 0)
    mb = size / 1024 / 1024
    if mb >= 500:
        return "#e74c3c"  # red
    elif mb >= 200:
        return "#e67e22"  # orange
    elif mb >= 100:
        return "#f1c40f"  # yellow
    else:
        return "#2ecc71"  # green
lines = [
    'digraph deps {',
    '  rankdir=LR;',
    '  node [shape=box, style="filled,rounded", fontname="sans-serif", fontsize=10];',
    '  edge [color="#bbbbbb"];',
]
for path in sorted(big):
    node_id = path.replace("/", "_").replace("-", "_").replace(".", "_")
    lines.append(
        f'  "{node_id}" [label="{label(path)}", fillcolor="{color(path)}"];'
    )
for path in sorted(big):
    src = path.replace("/", "_").replace("-", "_").replace(".", "_")
    for ref in sorted(info[path].get("references", [])):
        if ref != path and ref in big:
            dst = ref.replace("/", "_").replace("-", "_").replace(".", "_")
            edge_size = fmt_size(closure_size(ref))
            lines.append(f'  "{src}" -> "{dst}" [label="+{edge_size}", fontsize=8];')
lines.append("}")
dot = "\n".join(lines)
subprocess.run(
    ["dot", "-Tsvg", "-o", out],
    input=dot,
    text=True, check=True,
)
return out

First we ask nix for the full closure of the root path. The --closure-size flag makes nix compute each path’s transitive closure size natively, which is much faster than recomputing it in Python.

r = subprocess.run(
    ["nix", "path-info", "-r", "--json", "--closure-size", store_path],
    capture_output=True, text=True,
)
if r.returncode != 0:
    raise RuntimeError(f"nix path-info failed: {r.stderr}")
raw = json.loads(r.stdout)

# nix path-info --json returns a dict keyed by store path
if isinstance(raw, list):
    info = {p["path"]: p for p in raw}
else:
    info = {path: data for path, data in raw.items()}

def closure_size(path):
    return info[path].get("closureSize", 0)

When focus is set, we restrict the graph to only the closure of the matching package. This lets you zoom into a single package to understand why it pulls in so much. We walk the reference graph with BFS to find all transitive dependencies of the focused path.

if focus:
    focus_path = [p for p in info if re.search(re.escape(focus), p)]
    if not focus_path:
        raise RuntimeError(f"no store path matching '{focus}'")
    focus_path = focus_path[0]
    relevant = set()
    stack = [focus_path]
    while stack:
        p = stack.pop()
        if p in relevant or p not in info:
            continue
        relevant.add(p)
        for ref in info[p].get("references", []):
            if ref not in relevant:
                stack.append(ref)
    info = {p: info[p] for p in relevant if p in info}

In focus mode, we filter by closure size so that small intermediary packages that pull in heavy dependencies remain visible — this is the whole point of zooming in. On the full system graph, we filter by own size to keep the graph readable and fast to render.

if focus:
    big = {path for path in info if closure_size(path) >= min_bytes}
else:
    big = {path for path in info if info[path].get("narSize", 0) >= min_bytes}

Formatting helpers to make the graph readable: human-friendly sizes and color coding by own size to quickly spot the heaviest individual paths.

def fmt_size(size):
    if size >= 1024 * 1024 * 1024:
        return f"{size / 1024 / 1024 / 1024:.1f} GB"
    elif size >= 1024 * 1024:
        return f"{size // (1024 * 1024)} MB"
    else:
        return f"{size // 1024} KB"

def label(path):
    hash_prefix = re.search(r"^/nix/store/([a-z0-9]{8})", path).group(1)
    name = re.sub(r"^/nix/store/[a-z0-9]+-", "", path)
    own = info[path].get("narSize", 0)
    clos = closure_size(path)
    return f"{name} ({hash_prefix})\\n{fmt_size(own)} (closure: {fmt_size(clos)})"

def color(path):
    size = info[path].get("narSize", 0)
    mb = size / 1024 / 1024
    if mb >= 500:
        return "#e74c3c"  # red
    elif mb >= 200:
        return "#e67e22"  # orange
    elif mb >= 100:
        return "#f1c40f"  # yellow
    else:
        return "#2ecc71"  # green

We emit Graphviz DOT syntax: one node per big path, one edge per direct reference between big paths. Edge labels show the closure size of the target to help estimate how much removing that dependency would save.

lines = [
    'digraph deps {',
    '  rankdir=LR;',
    '  node [shape=box, style="filled,rounded", fontname="sans-serif", fontsize=10];',
    '  edge [color="#bbbbbb"];',
]
for path in sorted(big):
    node_id = path.replace("/", "_").replace("-", "_").replace(".", "_")
    lines.append(
        f'  "{node_id}" [label="{label(path)}", fillcolor="{color(path)}"];'
    )
for path in sorted(big):
    src = path.replace("/", "_").replace("-", "_").replace(".", "_")
    for ref in sorted(info[path].get("references", [])):
        if ref != path and ref in big:
            dst = ref.replace("/", "_").replace("-", "_").replace(".", "_")
            edge_size = fmt_size(closure_size(ref))
            lines.append(f'  "{src}" -> "{dst}" [label="+{edge_size}", fontsize=8];')
lines.append("}")
dot = "\n".join(lines)

Finally, render the DOT graph to SVG using Graphviz.

subprocess.run(
    ["dot", "-Tsvg", "-o", out],
    input=dot,
    text=True, check=True,
)
return out