Content-Addressable Paths

Updated

September 12, 2026

Content-Addressable Paths

Most Nix store paths you meet are input-addressed: the hash is a digest of the derivation (builder, env, input paths), not of the bytes that came out. Content-addressed (CA) paths hash the output itself. The boring default is: understand the difference, keep producing ordinary input-addressed builds, and let fixed-output derivations (fetchers) be the CA paths you actually use every day.

Mental model

Kind Hash comes from Same output, different builder?
Input-addressed The .drv (inputs, builder, env) Two different store paths
Content-addressed / FOD The output bytes (and hash mode) Same store path

Input-addressed is why a comment in a build script rebuilds everything downstream: the .drv changed, so the output path changed, even if gcc emitted identical bytes.

Fixed-output derivations (the fetchurl / fetchFromGitHub family) are the everyday CA case. You declare hash = "sha256-…"; Nix may use the network; it then checks the bytes against that hash. The store path is a function of the hash, not of the URL. Change the URL to a mirror, keep the hash, keep the path.

Experimental CA derivations for ordinary builds exist (contentAddressed in Nix). They are not the boring default for a desk workstation. The platform-engineering internals chapter covers them later.

Input-addressed:
  .drv  ──hash──►  /nix/store/AAAA-desk-api
                    (even if bytes == yesterday's BBBB-desk-api)

Fixed-output:
  declared hash  ──►  /nix/store/HHHH-source.tar.gz
  (URL is just how we get the bytes; the hash is identity)

Worked examples

Case 1: Input hash changes when the builder changes

Save as banner-a.nix:

# banner-a.nix
let
  pkgs = import <nixpkgs> {};
in
derivation {
  name = "desk-banner";
  system = builtins.currentSystem;
  builder = "${pkgs.bash}/bin/bash";
  args = [ "-c" "echo 'Desk System Online' > $out" ];
}

Save as banner-b.nix — same bytes out, extra comment in the script:

# banner-b.nix
let
  pkgs = import <nixpkgs> {};
in
derivation {
  name = "desk-banner";
  system = builtins.currentSystem;
  builder = "${pkgs.bash}/bin/bash";
  args = [ "-c" "echo 'Desk System Online' > $out  # lab note" ];
}
nix-instantiate banner-a.nix
nix-instantiate banner-b.nix

Output:

/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-desk-banner.drv
/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-desk-banner.drv

Two .drv files. Two future output paths. The echo is the same; the input is not.

Case 2: Store paths are immutable, not “updated in place”

touch $(readlink -f $(which nix)) 2>&1 || true

Output:

touch: cannot touch '/nix/store/…-nix-2.35.2/bin/nix': Permission denied

Nix never overwrites AAAA-desk-banner with new bytes. It builds CCCC-desk-banner and swings a profile symlink. That is input-addressing plus immutability working together.

Case 3: Fixed-output identity is the hash

Save as fod.nix:

# fod.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.fetchurl {
  url = "https://cache.nixos.org/nix-cache-info";
  hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
}
nix-build fod.nix

The build fails (unless you lucked into that hash). The error is the point:

error: hash mismatch in fixed-output derivation '/nix/store/…-nix-cache-info':
         specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
            got:    sha256-<the real hash>

Paste the got value into hash, rebuild, and the path is now determined by those bytes. Point url at a different mirror of the same file and the store path stays put.

Case 4: Why CA helps a cache

If two CI jobs fetch the same tarball with the same hash, they share one store path. If two CI jobs compile the same C file with two different comment-only builder changes, input-addressed Nix stores two outputs even when cmp would say the binaries match.

That waste is real. It is also cheaper than debugging “why did the binary change when the source didn’t” because a timestamp leaked into an input. Boring teams live with input-addressed builds and pin fetch hashes.

Case 5: Inspect which kind a path is

nix path-info --json $(nix-build '<nixpkgs>' -A hello --no-out-link) | head

Look for ca / narHash fields when present. Ordinary hello from nixpkgs is input-addressed. A fetchurl output is fixed-output. You do not need to memorise JSON; you need to know which family you are looking at when a cache misses.

The trap

The trap is turning off the hash (hash = ""; or sha256 = lib.fakeSha256 left in production) so the fetch “just works.” The next time GitHub retags, or a mirror is replaced, you silently take new bytes at the same URL. The whole point of a FOD is the mismatch error.

The other trap is enabling experimental CA derivations on a laptop because a blog post promised automatic sharing. Keep CA-for-builds in the lab until the whole team agrees. Fetchers already gave you the CA that matters.

The boring rule

  • Everyday builds are input-addressed. A .drv change is a new path, even if the bytes look the same.
  • Fetchers are fixed-output. Identity is the hash you wrote.
  • Never leave a dummy hash in a committed file.
  • Do not chmod the store to “fix” a path. Build a new one.
  • Treat experimental content-addressed builds as optional, not as the desk default.

Try this

  1. Change only the name in banner-a.nix from desk-banner to desk-banner-v2, instantiate, and confirm the .drv hash moved.
  2. Run Case 3, copy the got hash, rebuild, then run nix path-info -Sh result and note the size.
  3. Flip one character of the working hash and read the mismatch error. That error is success: Nix refused the bytes.
  4. Explain in one sentence, to a colleague, why hello from nixpkgs and a fetchurl tarball are hashed differently.