~/ ~/documents ~/software ~/pictures github (opens in new tab)

Nix Purity and the Illusion of Isolation

Purity is a foundational guarantee in Nix, but it is often discussed in abstract terms. In reality, a pure build is straightforward: it is a build where the result depends only on explicitly declared inputs. Nothing sneaky, nothing hidden, and no ambient host state allowed.

That means:

A build isn’t pure just because it succeeds twice in a row. It is pure if the same declared inputs produce the exact same result, regardless of when, where, or on what machine it is executed.

Impurity usually arrives disguised as convenience. The most common leak is ambient environment state.

Environment variables are the first boundary leak, especially when evaluation-time behavior is confused with shell runtime behavior.

At evaluation time, builtins.getEnv reads host variables directly:

IMPURE="foo" nix-instantiate --eval -E 'builtins.getEnv "IMPURE"'
# "foo"

So if an expression uses getEnv, output can vary from machine to machine:

buildPhase = ''
  echo ${builtins.getEnv "TOKEN"} > $out
'';

Flakes reduce this risk by defaulting to pure evaluation. In normal flake mode, builtins.getEnv returns an empty string unless you explicitly opt into impurity:

IMPURE="foo" nix build .#impure
# expression sees ""

IMPURE="foo" nix build .#impure --impure
# expression sees "foo"

In interactive workflows, keep this distinction sharp:

If your expression calls builtins.getEnv, --keep-env-var alone is insufficient. You need --impure for evaluation, and optionally -k for runtime:

IMPURE="foo" nix develop .#impure --impure --keep-env-var IMPURE

Environment-variable impurity is not always wrong, but it must be explicit, scoped, and documented.

Another leak appears through flake input overrides: controlled, useful, and still impure.

Impurity can also enter through input substitution. A pinned flake can still be evaluated against ad hoc external content via --override-input.

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  inputs.IMPURE.url = "file+file:///dev/null";
  inputs.IMPURE.flake = false;

  outputs = { nixpkgs, IMPURE, ... }: {
    packages.x86_64-linux.impure = nixpkgs.legacyPackages.x86_64-linux.stdenvNoCC.mkDerivation {
      name = "impure";
      dontUnpack = true;
      buildPhase = ''
        cat ${IMPURE} > $out
      '';
    };
  };
}

Then inject data during invocation:

nix build .#impure \
  --no-update-lock-file \
  --override-input IMPURE file+file://<(printf %s "foo")

cat result
# foo

--no-update-lock-file keeps flake.lock untouched, but behavior still changed outside repository state. This is useful for experimentation and local workflows, but it is not strictly reproducible unless the substituted input is stable, addressable, and shared.

The deeper problem is sandbox isolation itself, where the boundary can look strict but still admit hidden host influence.

Even when expressions and inputs look pinned, reproducibility can still leak through sandbox configuration.

A repository can be perfectly version-controlled and pinned, yet still fail the purity test if it:

Nix enforces build purity by placing derivation executions inside an isolated sandbox. A derivation specifies the exact inputs, build commands, and expected outputs. In a proper sandbox, the build toolchain sees only what has been explicitly provided.

However, hermeticity rests on a fragile assumption: the derivation must be a complete and total description of the build. When a dependency escapes the boundary of the derivation recipe, input-addressing breaks.

Nix breaks this boundary through sandbox-paths: a feature that mounts host filesystem paths directly into the build environment. Because these mounted paths are not recorded inside the derivation recipe, they can silently alter runtime behavior.

Consider a minimal Nix derivation that checks for a host file /truth:

derivation {
  name = "answer";
  system = "x86_64-linux";
  builder = "/bin/sh";
  args = [ "-c" ''
      if [ -f /truth ]; then read -r x < /truth; else x=4; fi
      echo "2 + 2 = $x" > $out
    '' ];
}

Evaluating and realizing this derivation produces a deterministic store path and output:

$ nix-instantiate ./answer.nix
/nix/store/xbik44...-answer.drv

$ nix-store --realise /nix/store/xbik44...-answer.drv
/nix/store/qba6hd...-answer

$ cat /nix/store/qba6hd...-answer
2 + 2 = 4

Now, inject a host path into the sandbox environment without altering the underlying Nix expression:

$ echo 5 > /tmp/truth
$ nix-store --delete /nix/store/qba6hd...-answer

$ nix-store --realise /nix/store/xbik44...-answer.drv \
    --option extra-sandbox-paths "/truth=/tmp/truth"
/nix/store/qba6hd...-answer

$ cat /nix/store/qba6hd...-answer
2 + 2 = 5

The derivation file (.drv) remains byte-identical. The computed output hash is unchanged. Yet, the realized artifact has been completely mutated.

This issue isn’t limited to manual command-line overrides like --option extra-sandbox-paths. Default sandbox-paths vary based on how the Nix package manager binary itself was built.

For instance, Nix defaults to mounting /bin/sh from a compile-time macro (SANDBOX_SHELL) defined in src/libstore/globals.cc:

#if (defined(__linux__) || defined(__FreeBSD__)) && defined(SANDBOX_SHELL)
    sandboxPaths = {{"/bin/sh", {.source = SANDBOX_SHELL}}};
#endif

If two host machines run identical versions of Nix, but one binary was compiled with SANDBOX_SHELL pointing to busybox and the other to bash (or compiled without a sandbox shell entirely), they can synthesize different build results from identical derivation hashes.

Why doesn’t Nix simply hash sandbox-paths into the derivation metadata to fix this leak?

Because doing so introduces a fundamental design conflict: incorporating host sandbox paths into derivation hashes destroys binary cache sharing across non-identical host environments.

Universal binary caching depends on the assumption that a given .drv produces a predictable output regardless of host specifics. Yet, that very assumption creates the vector for silent cache poisoning: if ambient host configuration dictates build execution outside the derivation, repeatability becomes an illusion.

This is why operational discipline matters more than slogans.

Impurity is sometimes necessary in real engineering work:

The rule is not “never be impure.” The rule is “never be accidentally impure.”

Make impurity visible in command history, CI config, and team docs so everyone can distinguish cache-safe reproducible builds from intentionally host-coupled workflows.

True purity in software delivery starts with a simple question:

Reproducible from which inputs, under what constraints, and with what ambient state excluded?

If that answer is ambiguous, the build is not truly hermetic. Nix provides powerful primitives for isolating builds and bounding dependencies, but purity in practice requires continuous vigilance against implicit host leaks and sandbox escapes.