lazy evaluation

why nix only evaluates what it needs and what that means in practice.

“i'll get to it eventually”
lazy evaluation, and also this readme

nix is lazy. nothing evaluates until something demands the value.

let x = throw "boom"; in 42
# 42

x is bound to throw "boom". nothing asks for x. the throw never runs.

thunks

x = 1 + 2 does not compute 3 immediately. nix stores a thunk : a pointer to the expression plus the variables in scope. the first time something demands x, nix evaluates the thunk and caches the result. subsequent accesses reuse the cache.

each expression is computed at most once.

nixpkgs

nixpkgs defines over 100,000 packages.

pkgs = import <nixpkgs> {};

nix does not evaluate 100,000 package definitions. it creates an attribute set of thunks. each package is a suspended computation.

pkgs.curl

only now does nix force the curl thunk. that forces curl's dependencies, which force theirs. the tens of thousands of packages you did not ask for are never touched.

without laziness, import <nixpkgs> {} would take minutes and gigabytes. with it, near-instant.

stepping through

step through this expression. watch which bindings get forced:

let ... in result
bindings
demand-driven evaluation
name
"curl"
version
"8.18.0"
debug
builtins.trace "evaluating debug" true
unused
throw "boom"
result
"${name}-${version}"
all bindings suspended.

debug calls builtins.trace. unused calls throw. neither runs. nix only evaluates what the result demands.

lazy attribute sets

each attribute is a separate thunk:

s = { a = 1; b = throw "boom"; }
s.a   # 1

accessing s.a does not force s.b. 100,000 attributes in nixpkgs, and you only pay for the ones you touch.

when it bites

error locations

let f = x: { a = x + 1; }; in (f "hello").a
# error: cannot coerce a string to an integer

the error fires when .a is forced, not where f was called. definition site and evaluation site are different. stack traces can be confusing.

infinite recursion

let x = x; in x
# error: infinite recursion encountered

nix detects simple cycles. indirect ones are harder:

let a = { x = b.x; }; b = { x = a.x; }; in a.x
# error: infinite recursion encountered

rec sets are the usual culprit.

forcing evaluation

builtins.seq forces its first argument, returns the second:

builtins.seq (throw "checked") "hello"
# error: checked

seq is shallow. for attribute sets it checks that the set exists but does not force individual attributes. builtins.deepSeq forces everything:

builtins.deepSeq { a = 1; b = throw "deep"; } "ok"
# error: deep

builtins.seq { a = 1; b = throw "deep"; } "ok"
# "ok"

most nix code never touches either.

consequences

laziness is not opt-in. every let binding, every attribute, every function body is a thunk until demanded.

  • nixpkgs is fast to import because nothing evaluates upfront
  • lib.mkIf in nixos modules works because unevaluated branches are never forced
  • overrideAttrs is cheap because original attributes are thunks that get replaced, not recomputed
  • nix-env -qa is slow because listing all packages forces every name and version attribute

derivations is where the language meets the store. a derivation is an attribute set that nix knows how to build. store paths, references, functions, and lazy values come together there.