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:
--impure changes evaluation semantics, allowing
expressions to read host variables.--keep-env-var (or -k) forwards selected
variables into shell runtime.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 IMPUREEnvironment-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:
gcc) existNix 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 = 4Now, 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 = 5The 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}}};
#endifIf 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.