How Nix computes a store path
How Nix computes a store path
Goal: Recompute /nix/store/<32 characters>-<name> by hand for each kind of store object: a builtins.toFile text, a source tree added by NAR hash, a flat fixed-output file, an input-addressed derivation output, and the .drv file itself. Then confirm that the reimplementation agrees with Nix byte for byte. Once you can do that, cache misses, “why did this rebuild?”, and “why did nothing rebuild when I changed the URL?” have exact answers.
Version note: written against Nix 2.35.3, the latest upstream tag at the time of writing (nixos.org’s download page still lists 2.35.2), and NixOS 26.05. Every nix command below really ran, using a statically linked Nix 2.35.3 binary on a box with no /nix, no daemon, and no root. It ran against a chroot store (--store /tmp/nixlab/store), whose logical store directory is still /nix/store, so every path printed is the path a NixOS host would print. On your own NixOS machine, drop --store "$S" (with nix-command enabled, as it usually is). The reimplementation uses only the Python standard library and was run with Python 3.13.5.
Thesis
A store path is not a random ID and not the hash of “the package”. It is the SHA-256 of a short fingerprint string, type:sha256:<inner digest>:<store dir>:<name>, folded to 160 bits and written in Nix’s own base-32 alphabet. Everything Nix means by “input-addressed” and “content-addressed” lives in two parts of that fingerprint: the type, and what the inner digest is a hash of. That can be file bytes, a NAR, a derivation’s ATerm with fixed-output inputs factored out, or a fixed:out: string. The algorithm is specified in the Nix manual’s store path calculation. It is about 150 lines of Python, and it explains several things people normally learn by surprise: renaming a source changes its path, mirrors of the same tarball share an output path, and a store at /opt/nix/store can never share binaries with cache.nixos.org.
Mental model
| Store object | type in the fingerprint |
Inner digest is SHA-256 of |
|---|---|---|
builtins.toFile, every .drv file |
text + :<ref> for each reference |
the file’s bytes |
./src, builtins.path, nix store add (NAR, SHA-256) |
source + :<ref>… (+ :self) |
the NAR serialisation |
Flat fixed-output (fetchurl, --mode flat) |
output:out |
the string fixed:out:sha256:<hex>: |
Input-addressed derivation output out |
output:out |
the derivation’s ATerm, modulo fixed-output derivations |
- Two hashes, then a fold.
sha256(fingerprint)is 32 bytes. Nix XOR-folds it to 20 (out[i % 20] ^= h[i]) and encodes the 160 bits as 32 characters in base-32, using an alphabet withoute,o,uort. - The name and the store directory are hashed too. The manual says so explicitly, “to make sure that changes to either of those are reflected in the hash”. The same bytes at
/gnu/storeor under another name get a different digest. - NAR is the canonical form of a file tree. It records file type, contents, the executable bit, symlink targets, and sorted directory entries. It records nothing else: no mtimes, owners, or group/other permission bits. That is why
nix hash pathis reproducible across machines (Nix Archive format). - “Modulo” means fixed-output inputs are replaced by their promised output. When Nix hashes a derivation to name its outputs, every input
.drvpath is replaced by that derivation’s modulo hash. For a fixed-output input, that hash comes only from its declared output hash and path, not from its URL or builder (input-addressing). Case 5 is the payoff.
The code
Two files, standard library only. storepath.py holds the fingerprint, the fold, base-32, NAR, and the three content-addressed path kinds:
# storepath.py - how Nix turns content and recipes into /nix/store/<hash>-<name>
# Standard library only. Mirrors the algorithm in the Nix manual's
# "Store Path" and "Content-Addressing Store Objects" pages.
import hashlib
import os
import stat
STORE_DIR = "/nix/store"
ALPHABET = "0123456789abcdfghijklmnpqrsvwxyz" # Nix base-32: no e, o, u, t
def nix32(digest: bytes) -> str:
"""Nix's base-32: 5 bits at a time, starting from the END of the digest."""
n = (len(digest) * 8 - 1) // 5 + 1
out = []
for k in range(n - 1, -1, -1):
b = k * 5
i, j = divmod(b, 8)
c = digest[i] >> j
if i + 1 < len(digest):
c |= digest[i + 1] << (8 - j)
out.append(ALPHABET[c & 0x1F])
return "".join(out)
def compress(digest: bytes, size: int = 20) -> bytes:
"""XOR-fold a 32-byte SHA-256 down to 20 bytes (160 bits)."""
out = bytearray(size)
for i, b in enumerate(digest):
out[i % size] ^= b
return bytes(out)
def make_store_path(kind: str, inner_hex: str, name: str) -> str:
store_dir = STORE_DIR # read at call time, so callers can change it
fingerprint = f"{kind}:sha256:{inner_hex}:{store_dir}:{name}"
digest = hashlib.sha256(fingerprint.encode()).digest()
return f"{store_dir}/{nix32(compress(digest))}-{name}"
def refs(kind: str, references=()) -> str:
return kind + "".join(":" + r for r in sorted(references))
# --- NAR: the canonical serialisation of a file system object -------------
def _str(b: bytes) -> bytes:
return len(b).to_bytes(8, "little") + b + b"\0" * (-len(b) % 8)
def nar(path: str) -> bytes:
def node(p):
st = os.lstat(p)
out = _str(b"(")
if stat.S_ISLNK(st.st_mode):
out += _str(b"type") + _str(b"symlink") + _str(b"target") + _str(os.readlink(p).encode())
elif stat.S_ISDIR(st.st_mode):
out += _str(b"type") + _str(b"directory")
for entry in sorted(os.listdir(p)):
out += _str(b"entry") + _str(b"(") + _str(b"name") + _str(entry.encode())
out += _str(b"node") + node(os.path.join(p, entry)) + _str(b")")
else:
out += _str(b"type") + _str(b"regular")
if st.st_mode & 0o100:
out += _str(b"executable") + _str(b"")
out += _str(b"contents") + _str(open(p, "rb").read())
return out + _str(b")")
return _str(b"nix-archive-1") + node(path)
# --- the three ways a store path is born -----------------------------------
def text_path(name: str, contents: bytes, references=()) -> str:
"""builtins.toFile, and every .drv file."""
return make_store_path(refs("text", references), hashlib.sha256(contents).hexdigest(), name)
def source_path(path: str, name: str, references=()) -> str:
"""Files added to the store by NAR hash: ./src, builtins.path, nix store add."""
return make_store_path(refs("source", references), hashlib.sha256(nar(path)).hexdigest(), name)
def fixed_output_path(name: str, sha256_hex: str, recursive: bool) -> str:
"""Fixed-output derivations (fetchurl, ...) and `nix store add --mode flat`."""
if recursive:
return make_store_path("source", sha256_hex, name)
inner = hashlib.sha256(f"fixed:out:sha256:{sha256_hex}:".encode()).hexdigest()
return make_store_path("output:out", inner, name)drv.py holds derivations: the ATerm serialisation (derivation ATerm format), hashDerivationModulo, and what derivation { … } does at evaluation time. It covers single-output input-addressed derivations and flat fixed-output derivations, which is enough for every case below:
# drv.py - derivations: ATerm, hashDerivationModulo, output and .drv paths
import hashlib
from storepath import make_store_path, text_path
def _s(x: str) -> str:
return '"' + x.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") \
.replace("\r", "\\r").replace("\t", "\\t") + '"'
def _list(items) -> str:
return "[" + ",".join(items) + "]"
def aterm(d: dict, input_drvs: dict) -> str:
"""Serialise like a .drv file. input_drvs maps a key (a .drv path, or a
hash when computing the modulo) to the list of outputs used."""
outs = _list("(" + ",".join(_s(v) for v in (o, *d["outputs"][o])) + ")" for o in sorted(d["outputs"]))
drvs = _list("(" + _s(k) + "," + _list(_s(o) for o in sorted(v)) + ")" for k, v in sorted(input_drvs.items()))
srcs = _list(_s(p) for p in sorted(d["input_srcs"]))
args = _list(_s(a) for a in d["args"])
env = _list("(" + _s(k) + "," + _s(v) + ")" for k, v in sorted(d["env"].items()))
return f"Derive({outs},{drvs},{srcs},{_s(d['system'])},{_s(d['builder'])},{args},{env})"
def is_fixed_output(d: dict) -> bool:
return d["outputs"].keys() == {"out"} and d["outputs"]["out"][2] != ""
def hash_modulo(d: dict) -> str:
"""hashDerivationModulo: the hash that names a derivation's outputs."""
if is_fixed_output(d):
path, algo, h = d["outputs"]["out"]
return hashlib.sha256(f"fixed:out:{algo}:{h}:{path}".encode()).hexdigest()
# Replace every input .drv path by *its* modulo hash, then hash the ATerm.
masked = {hash_modulo(dep): outs for dep, outs in d["input_drv_objs"]}
return hashlib.sha256(aterm(d, masked).encode()).hexdigest()
def finalise(name: str, system: str, builder: str, args, env: dict, input_drv_objs=(), input_srcs=(),
fixed=None):
"""What `derivation { ... }` does at evaluation time (input-addressed or flat FOD)."""
from storepath import fixed_output_path
env = {"name": name, "system": system, "builder": builder, **env}
d = {"system": system, "builder": builder, "args": list(args), "input_srcs": list(input_srcs),
"input_drv_objs": list(input_drv_objs)}
if fixed: # fixed=("sha256", hex): outputHashMode = "flat"
algo, h = fixed
out = fixed_output_path(name, h, recursive=False)
d["outputs"] = {"out": (out, algo, h)}
d["env"] = {**env, "out": out}
else:
d["outputs"] = {"out": ("", "", "")} # 1. blank the output path ...
d["env"] = {**env, "out": ""} # ... everywhere it appears
out = make_store_path("output:out", hash_modulo(d), name) # 2. name it by the modulo hash
d["outputs"] = {"out": (out, "", "")} # 3. write it back
d["env"]["out"] = out
real_drvs = {dep["drv_path"]: outs for dep, outs in d["input_drv_objs"]}
d["aterm"] = aterm(d, real_drvs)
d["out_path"] = out
d["drv_path"] = text_path(name + ".drv", d["aterm"].encode(),
references=[*d["input_srcs"], *real_drvs])
return dSet up the lab once. On a NixOS host, skip S and every --store "$S":
mkdir -p /tmp/nixlab && cd /tmp/nixlab # storepath.py, drv.py and the case files live here
S=/tmp/nixlab/store # chroot store; logical store dir is still /nix/store
nix --version
python3 --versionnix (Nix) 2.35.3
Python 3.13.5
Worked examples
Case 1: builtins.toFile, one step at a time
# case1.py - builtins.toFile "hello.txt" "hi\n", by hand
import hashlib
from storepath import nix32, compress, text_path
contents = b"hi\n"
inner = hashlib.sha256(contents).hexdigest()
fingerprint = f"text:sha256:{inner}:/nix/store:hello.txt"
digest = hashlib.sha256(fingerprint.encode()).digest()
print("fingerprint :", fingerprint)
print("sha256 :", digest.hex())
print("xor-fold 20 :", compress(digest).hex())
print("nix32 :", nix32(compress(digest)))
print("store path :", text_path("hello.txt", contents))python3 case1.py
nix --store "$S" eval --raw --expr 'builtins.toFile "hello.txt" "hi\n"'; echoOutput:
fingerprint : text:sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4:/nix/store:hello.txt
sha256 : 0e898c0e1e74df971c7c03b1083fa48e0599910582c4f4c47af409d4d5cdb2b3
xor-fold 20 : 8c4d78ca6480d643c9b1b102083fa48e05999105
nix32 : 0n8rj1cflhzhh0min74l7ml0ck57hkcc
store path : /nix/store/0n8rj1cflhzhh0min74l7ml0ck57hkcc-hello.txt
/nix/store/0n8rj1cflhzhh0min74l7ml0ck57hkcc-hello.txt
The first five lines are the algorithm and the last line is Nix, and they match. Notice what the inner digest is: 98ea6e4f… is simply sha256("hi\n"). A text object is addressed by its contents plus its name. Change either one and the path changes.
Case 2: A directory: NAR bytes, NAR hash, source path
Build a small tree with one executable, one plain file, and a symlink, then serialise and hash it in Python.
# case2.py - NAR-hash a directory and predict its store path
import base64
import hashlib
import sys
from storepath import nar, nix32, source_path
tree = sys.argv[1]
blob = nar(tree)
digest = hashlib.sha256(blob).digest()
open("tree.py.nar", "wb").write(blob)
print("NAR bytes :", len(blob))
print("sha256 hex :", digest.hex())
print("sha256 SRI :", "sha256-" + base64.b64encode(digest).decode())
print("sha256 nix32 :", nix32(digest))
print("store path :", source_path(tree, "tree"))mkdir -p tree/bin
printf '#!/bin/sh\necho hello\n' > tree/bin/hello && chmod 755 tree/bin/hello
printf 'hi\n' > tree/README && chmod 644 tree/README
ln -s bin/hello tree/run
python3 case2.py tree
nix nar pack tree > tree.nix.nar && cmp tree.py.nar tree.nix.nar && echo "NAR bytes identical"
nix hash path tree
nix hash path --base32 tree
nix --store "$S" store add treeOutput:
NAR bytes : 896
sha256 hex : 76089773df8aecb93056dbf6e3e015abb02b03433441699c061aa6988fcf894e
sha256 SRI : sha256-dgiXc9+K7LkwVtv24+AVq7ArA0M0QWmcBhqmmI/PiU4=
sha256 nix32 : 0kl9ry7ri9hs0sf6jh9l8c1jpc5b2phf7xnvaqqbkv4avxrrf23n
store path : /nix/store/zb37ng1sjjd4gilsna650k7mankgdjc9-tree
NAR bytes identical
sha256-dgiXc9+K7LkwVtv24+AVq7ArA0M0QWmcBhqmmI/PiU4=
0kl9ry7ri9hs0sf6jh9l8c1jpc5b2phf7xnvaqqbkv4avxrrf23n
/nix/store/zb37ng1sjjd4gilsna650k7mankgdjc9-tree
The 896 NAR bytes are identical to Nix’s, so the hash is identical in all three spellings (hex, SRI base-64, Nix base-32), and so is the source: store path. Now change what NAR ignores, and then what it keeps:
touch -d 2001-01-01 tree/README && chmod g+w tree/README # mtime and group bits: not in NAR
nix hash path tree
chmod -x tree/bin/hello # executable bit: in NAR
nix hash path tree
chmod +x tree/bin/helloOutput:
sha256-dgiXc9+K7LkwVtv24+AVq7ArA0M0QWmcBhqmmI/PiU4=
sha256-mQ8ZVZA7F6E7KJMY4dq0jELaewFiaRllv114SJv3CzY=
Case 3: One file, three addresses
The same hi.txt added three ways: as a flat file, as a NAR, and as a NAR under a different name.
# case3.py - one file, three addresses: flat, recursive (NAR), and a different name
import hashlib
import sys
from storepath import fixed_output_path, nar
path = sys.argv[1]
flat = hashlib.sha256(open(path, "rb").read()).hexdigest()
rec = hashlib.sha256(nar(path)).hexdigest()
print("sha256(file bytes):", flat)
print("sha256(NAR) :", rec)
print("flat hi.txt :", fixed_output_path("hi.txt", flat, recursive=False))
print("recursive hi.txt :", fixed_output_path("hi.txt", rec, recursive=True))
print("recursive hello :", fixed_output_path("hello", rec, recursive=True))printf 'hi\n' > hi.txt
python3 case3.py hi.txt
nix --store "$S" store add --mode flat hi.txt
nix --store "$S" store add --mode nar hi.txt
nix --store "$S" store add --mode nar --name hello hi.txtOutput:
sha256(file bytes): 98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4
sha256(NAR) : 10551daed7fa467d0b58826e7eb9a0f2af7d693de319f2ef775fffcda244b9f6
flat hi.txt : /nix/store/a1qbm6bia1qj42ganmyz01knaai6d59p-hi.txt
recursive hi.txt : /nix/store/qs2vcw1sd6lzsvgvq6gggnw1v31qrm28-hi.txt
recursive hello : /nix/store/a5izqk5bgpxcrrnrm1m8n1fvyq2jlc52-hello
/nix/store/a1qbm6bia1qj42ganmyz01knaai6d59p-hi.txt
/nix/store/qs2vcw1sd6lzsvgvq6gggnw1v31qrm28-hi.txt
/nix/store/a5izqk5bgpxcrrnrm1m8n1fvyq2jlc52-hello
Three different paths for one 3-byte file. A flat SHA-256 is wrapped in fixed:out:sha256:<hex>: and goes through the output:out type. A NAR SHA-256 uses source directly. The manual’s historical note explains that NAR + SHA-256 “always uses the source:... construction, regardless of how it was produced”, which is why a fetchzip-style recursive fixed-output derivation and a manual nix store add of the same tree land on the same path. Renaming changes the path even though the hash of the content does not.
Case 4: An input-addressed derivation, byte for byte
derivation {
name = "hello";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo hi > $out" ];
}# case4.py - predict hello.nix's .drv bytes, outPath and drvPath
from drv import finalise
hello = finalise("hello", "x86_64-linux", "/bin/sh", ["-c", "echo hi > $out"], {})
print("outPath :", hello["out_path"])
print("drvPath :", hello["drv_path"])
open("hello.py.drv", "w").write(hello["aterm"])python3 case4.py
nix --store "$S" eval --raw -f hello.nix outPath; echo
drv=$(nix --store "$S" eval --raw -f hello.nix drvPath); echo "$drv"
cat "$S$drv"; echo
cmp hello.py.drv "$S$drv" && echo ".drv bytes identical"Output:
outPath : /nix/store/mjs27ix6ig2bkbi3s3sm470vrv4lf7ic-hello
drvPath : /nix/store/76w21n1f03fs5kw8fnffphx7qrqffw6r-hello.drv
/nix/store/mjs27ix6ig2bkbi3s3sm470vrv4lf7ic-hello
/nix/store/76w21n1f03fs5kw8fnffphx7qrqffw6r-hello.drv
Derive([("out","/nix/store/mjs27ix6ig2bkbi3s3sm470vrv4lf7ic-hello","","")],[],[],"x86_64-linux","/bin/sh",["-c","echo hi > $out"],[("builder","/bin/sh"),("name","hello"),("out","/nix/store/mjs27ix6ig2bkbi3s3sm470vrv4lf7ic-hello"),("system","x86_64-linux")])
.drv bytes identical
Nix does three things here, in this order. (1) It builds the derivation with every output path set to "", both in the outputs list and in the out environment variable, then serialises that to ATerm and hashes it. (2) It names the output make_store_path("output:out", that_hash, "hello"). (3) It writes the path back in, serialises the final ATerm, which is the .drv file, and stores that as a text object named hello.drv, with its input sources and input derivations as references. The output path is computed before the .drv exists, so the output cannot depend on the .drv path. That is the circularity “modulo” breaks.
Case 5: The “modulo”: two mirrors, one output path
A fixed-output “fetch” (the builder just prints a URL; nothing is built) and an app that copies it. Mirror a and mirror b differ only in the URL.
# app.nix - one fixed-output source, two different "mirrors", one consumer
let
greeting = url: derivation {
name = "greeting.txt";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo fetching ${url}; printf 'hi\\n' > $out" ];
outputHashMode = "flat";
outputHashAlgo = "sha256";
outputHash = "98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4";
};
app = src: derivation {
name = "app";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "cp ${src} $out" ];
};
in {
a = app (greeting "https://a.example/greeting.txt");
b = app (greeting "https://b.example/greeting.txt");
}# case5.py - the "modulo" in hashDerivationModulo
from drv import finalise
SHA = "98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4"
def greeting(url):
return finalise("greeting.txt", "x86_64-linux", "/bin/sh",
["-c", f"echo fetching {url}; printf 'hi\\n' > $out"],
{"outputHashMode": "flat", "outputHashAlgo": "sha256", "outputHash": SHA},
fixed=("sha256", SHA))
def app(src):
return finalise("app", "x86_64-linux", "/bin/sh", ["-c", f"cp {src['out_path']} $out"], {},
input_drv_objs=[(src, ["out"])])
for mirror in ("a", "b"):
src = greeting(f"https://{mirror}.example/greeting.txt")
top = app(src)
print(f"{mirror}: greeting.drvPath {src['drv_path']}")
print(f" greeting.outPath {src['out_path']}")
print(f" app.drvPath {top['drv_path']}")
print(f" app.outPath {top['out_path']}")for x in a b; do for k in drvPath outPath; do
echo "$x.$k $(nix --store "$S" eval --raw -f app.nix $x.$k)"; done; done
python3 case5.pyOutput:
a.drvPath /nix/store/ah3ma3iq16cc07b5a1q31h7zxjpalmfk-app.drv
a.outPath /nix/store/gxiksvnwx12gg9nlvcnqw884mfp2ppfk-app
b.drvPath /nix/store/zhd4fazximx9kzrich1rk4qfcy3a7y7s-app.drv
b.outPath /nix/store/gxiksvnwx12gg9nlvcnqw884mfp2ppfk-app
a: greeting.drvPath /nix/store/1f76makgrz9w9s6qsghadmvfv333fnzx-greeting.txt.drv
greeting.outPath /nix/store/0cd9wfphx5i9sly07cr00rzqn22d3lbj-greeting.txt
app.drvPath /nix/store/ah3ma3iq16cc07b5a1q31h7zxjpalmfk-app.drv
app.outPath /nix/store/gxiksvnwx12gg9nlvcnqw884mfp2ppfk-app
b: greeting.drvPath /nix/store/mpd6qm01vvislikzq4marrgwacpr272c-greeting.txt.drv
greeting.outPath /nix/store/0cd9wfphx5i9sly07cr00rzqn22d3lbj-greeting.txt
app.drvPath /nix/store/zhd4fazximx9kzrich1rk4qfcy3a7y7s-app.drv
app.outPath /nix/store/gxiksvnwx12gg9nlvcnqw884mfp2ppfk-app
Read it from the bottom up. The two greeting.txt.drv files differ, because the URL is in their args. Their output path is the same, because a flat fixed-output path depends only on name and outputHash. The two app.drv files differ, because each one names a different input .drv. Yet app’s output path is identical: when Nix hashed app to name its output, it replaced each input .drv path with sha256("fixed:out:sha256:<hash>:<outPath>"), and that string is the same for both mirrors. This is why moving a fetchurl to a mirror, or changing its curl flags, does not rebuild the world, and why a wrong outputHash is caught at fetch time rather than silently accepted.
The trap
Two things in the fingerprint are not your bytes, and both bite in practice. One is the recipe: an input-addressed path is named by the ATerm, not by what the build produces. The content-addressed internals chapter shows the rebuild this causes. The other is the store directory itself.
# trap.py - two things in the fingerprint that are not your bytes
import storepath
from drv import finalise
v1 = finalise("hello", "x86_64-linux", "/bin/sh", ["-c", "echo hi > $out"], {})
v2 = finalise("hello", "x86_64-linux", "/bin/sh", ["-c", "echo hi > $out # same output"], {})
print("recipe v1 :", v1["out_path"])
print("recipe v2 (comment):", v2["out_path"])
storepath.STORE_DIR = "/opt/nix/store"
v1_opt = finalise("hello", "x86_64-linux", "/bin/sh", ["-c", "echo hi > $out"], {})
print("v1 in /opt/nix/store:", v1_opt["out_path"])python3 trap.py
nix --store "$S" eval --raw --expr 'derivation { name = "hello"; system = "x86_64-linux"; builder = "/bin/sh"; args = [ "-c" "echo hi > $out # same output" ]; }'; echo
nix --store 'local?store=/opt/nix/store&real=/tmp/alt/store&state=/tmp/alt/state&log=/tmp/alt/log' eval --raw -f hello.nix outPath; echoOutput:
recipe v1 : /nix/store/mjs27ix6ig2bkbi3s3sm470vrv4lf7ic-hello
recipe v2 (comment): /nix/store/52vkb7gk02c7kdgmfzbzr1z860vsk9id-hello
v1 in /opt/nix/store: /opt/nix/store/9nyvidn6yc9hq5kcvw9iwk3hd71kzb6v-hello
/nix/store/52vkb7gk02c7kdgmfzbzr1z860vsk9id-hello
/opt/nix/store/9nyvidn6yc9hq5kcvw9iwk3hd71kzb6v-hello
A shell comment that cannot change the output still gives a new path, because the ATerm changed. Moving the store to /opt/nix/store, a common wish on machines where / is small or read-only, changes every hash. The binary cache is keyed by /nix/store/<hash>, so a store with a different directory gets no substitutes from cache.nixos.org and builds everything from source, including the compiler. If /nix is the problem, move the bytes rather than the name: a bind mount or a dedicated filesystem mounted at /nix.
The boring rule
- Keep the store directory
/nix/store. Relocate storage with mounts, never withstore=. - Treat a store path as
hash(type, inner digest, store dir, name). When a path changes unexpectedly, ask which of the four changed. - Use
nix hash path(NAR) for trees andnix hash file(flat) for single files, and know that they give different store paths for the same bytes. - Pin sources with fixed-output hashes. Mirrors and fetch flags then stay out of every downstream output path.
- When debugging a rebuild, diff the two
.drvfiles (nix derivation showon both), not the outputs. The output path is a pure function of the ATerm modulo fixed-output inputs.
Try this
- Add a reference to
text_path: computebuiltins.toFile "b" "${builtins.toFile "a" "x"}"in Python (the reference goes into the type astext:/nix/store/…-a) and compare withnix eval --raw --expr. - Give
hello.nixa second output (outputs = [ "out" "dev" ];). Extendfinaliseso both outputs are blanked before hashing and each is namedoutput:<id>with the namehelloorhello-dev. Check againstnix eval .dev.outPath. - Change Case 5’s fixed-output derivation to
outputHashMode = "recursive". Which line offixed_output_pathnow applies, and doesapp’s output path change? - Write the inverse of
nix32()in Python (decode 52 characters back to 32 bytes) and check it againstnix hash convert --hash-algo sha256 --to base16 0kl9ry7ri9hs0sf6jh9l8c1jpc5b2phf7xnvaqqbkv4avxrrf23nfrom Case 2. - Compute the path of a store object that refers to itself (
source:...:self). Which kind of build produces such an object, and why must the path be computed with the self-reference masked?
Sources
- Nix Reference Manual (2.35): Store path calculation (fingerprint grammar,
text/source/output:types,fixed:out:inner fingerprint, historical note onsource:), Store path, Content-addressing store objects, Nix Archive (NAR) format, Derivation ATerm file format, Input-addressing derivation outputs. - Nix command reference:
nix eval,nix store add,nix hash path,nix nar pack,nix derivation show(nix <command> --helpon 2.35.3). - Eelco Dolstra, The Purely Functional Software Deployment Model (PhD thesis, 2006), section 5.1, cited by the manual for the hash compression.
- Nix 2.35 release notes and the NixOS download page for the current versions (Nix 2.35.x, NixOS 26.05).