The evaluator

Updated

July 30, 2026

The evaluator

Goal: Explain thunks, force evaluation intentionally, reproduce and fix infinite recursion, and use trace / tryEval / seq / deepSeq without cargo-culting them.

Note

This chapter is language/evaluator depth. Prefer a disposable nix repl and tiny pure expressions before touching your host flake. A bad recursive module option can hang eval of the whole system.

Why this chapter exists

By the time you reach internals you already use laziness every rebuild: modules only force what activation needs. When eval explodes—infinite recursion encountered, mysterious null, or a multi-minute wait before any build starts—the bug is often a thunk cycle or accidental full-tree force, not a broken package.

Internals last (syllabus rule) means you can now map symptoms → mechanisms and fix the design rather than spraying deepSeq everywhere.


Theory 1 — Evaluation is a graph of thunks

Values vs thunks

Nix does not compute every subexpression when you write it. An unevaluated expression is a thunk: a promise to compute later, holding the expression body plus an environment of free variables.

Concept Meaning
Thunk Deferred computation; may be forced 0 or 1 times (then memoized)
WHNF Weak head normal form: outer constructor known ({…}, […], lambda, int, …) without forcing nested fields
NF Full normal form: nested values forced recursively
Memoization After force, the thunk is replaced by the result (shared)
let
  x = builtins.trace "forcing x" (1 + 1);
  y = { a = x; b = x; };
in
  y.a

You should see "forcing x" once. Both a and b share the same thunk identity until forced.

Why modules love laziness

# Sketch of the idea — not the real module system
config = {
  services.foo.enable = true;
  # huge attrset of options you never touch...
};
# Only options that are *demanded* by evaluation of the final system get forced.

If every option were strict, evaluating a NixOS config would always pay the full cost of every default expression, including unused services.

Eager edges you already know

Construct Eager? Notes
import ./file.nix Path resolve + parse/eval of the file File body still may return lazy values
Function application f x Forces f to a function; forces args per function body Body of f decides strictness
&& / \|\| Short-circuit Second arm may never force
if c then a else b Forces c Only one branch forces
builtins.seq a b Forces a to WHNF, returns b Classic “strictness injection”
builtins.deepSeq a b Forces a fully, returns b Expensive; tests and asserts
builtins.trace msg v Forces nothing about v except returns it Side effect for debugging
builtins.map f list Forces list spine as consumed Element force depends on f
String interpolation "${x}" Forces x to a stringable value Common accidental force site

Theory 2 — Infinite recursion patterns

Pattern A — Direct self-reference

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

Pattern B — Mutual recursion without a base case

let
  a = b + 1;
  b = a + 1;
in a

Pattern D — Module / option cycles (production shape)

Classic failure:

# module A
config.services.foo.settings.bar = config.services.baz.settings.qux;

# module B
config.services.baz.settings.qux = config.services.foo.settings.bar;

Or subtler:

options.my.paths.dataDir = lib.mkOption {
  type = lib.types.path;
  default = config.my.paths.root + "/data";  # forces config while defining defaults
};

Rule of thumb: defaults and apply functions should depend on options (or pure inputs), not on deep config that re-enters the same option.

Pattern E — with + shadowing confusion

let pkgs = { hello = "ok"; }; in
with pkgs;
let hello = hello; in  # which hello?
hello

Prefer explicit attr access in teaching examples; with is a readability tax on recursion debugging.

Pattern F — lib.fix / fixed points gone wrong

# conceptual — when extends and self-reference collide badly
lib.fix (self: {
  a = self.b;
  b = self.a;
})

Fixed points are powerful for overlays and module-ish composition; cycles still blow up when forced. Prefer small pure functions until you need fix.


Theory 3 — Debugging toolkit

builtins.trace

builtins.trace "here" value

Prints to stderr when the trace expression itself is forced (i.e. when something needs value through the trace).

builtins.tryEval

builtins.tryEval (throw "boom")
# → { success = false; value = false; }  # value is dummy on failure

builtins.tryEval 42
# → { success = true; value = 42; }

Use for soft probing—not for swallowing production errors silently. Infinite recursion may not be caught the way you hope; catalog behavior on your Nix version.

seq vs deepSeq

builtins.seq { a = throw "late"; } "outer-ok"
# → "outer-ok"   # only WHNF of the attrset

builtins.deepSeq { a = throw "late"; } "never"
# → error: late

lib.debug.traceVal / traceSeq / traceValSeq (nixpkgs)

When you have lib from nixpkgs:

lib.debug.traceVal someAttr
lib.debug.traceValSeqN 2 nested

Prefer library helpers over ad-hoc nested trace once you are in a flake with pkgs/lib.

NIX_SHOW_STATS and evaluator metrics

NIX_SHOW_STATS=1 nix eval --impure --expr '1+1'

Look for counts of thunks, allocations, and GC—useful when comparing “eval is slow” hypotheses, not as a daily dashboard.

nix-instantiate --eval / nix eval

nix eval --expr 'let x = x; in x'          # expect infinite recursion
nix eval --json .#nixosConfigurations.lab.config.networking.hostName

For module cycles, force one leaf option rather than the entire config.

Bisect strategy for real flakes

1. Force smallest leaf (hostname, one service enable)
2. Binary-search modules list (comment half of imports)
3. Within suspect module, force each option default path
4. Draw DAG of option dependencies on paper
5. Only then add temporary trace

Remove all temporary traces before merge.


Theory 4 — When to force (and when not to)

Goal Tool Risk
Ensure side-effect config is checked early assertions / warnings in modules Good default
Fail fast if a secret path missing assert in module, or activation script Prefer module-time when pure
Make a function strict in an arg seq at the start of the body Can defeat sharing
Serialize entire config for tests deepSeq or lib.generators.toJSON Huge cost if done casually
“Fix” mysterious laziness bugs with deepSeq everywhere Don’t Hides design issues; tanks eval
Warning

deepSeq is a diagnostic scalpel and a test hammer—not a seasoning for every module.


Theory 5 — Eval vs build vs activation (where time goes)

Phase What runs Failure shape
Eval Nix language → derivation graph / config attrset Type errors, infinite recursion, missing options
Build / substitute Realize store paths Compile errors, hash mismatch, sandbox
Activation Switch system generation systemd failures, scripts, secret decrypt

Symptoms that feel like “Nix is broken” are often eval. Timing with time nix eval … vs time nix build … separates the budget before you tune max-jobs (see Nix performance).


Worked example — Construct, diagnose, fix

Broken

# broken-cycle.nix
rec {
  root = "/var/lib/app";
  data = "${data}/files";   # oops: meant root
}
nix eval -f broken-cycle.nix data
# infinite recursion encountered

Fixed

rec {
  root = "/var/lib/app";
  data = "${root}/files";
}

Module-shaped fix

Prefer:

{ lib, config, ... }:
{
  options.my.app.root = lib.mkOption {
    type = lib.types.path;
    default = "/var/lib/app";
  };
  options.my.app.dataDir = lib.mkOption {
    type = lib.types.path;
    default = "${config.my.app.root}/files";  # depends on sibling option via config — OK if no cycle
  };
}

If root’s default ever depended on dataDir, you create a cycle. Draw a DAG of option dependencies when stuck.

Force a leaf, not the world

# Good: small demand
nix eval .#nixosConfigurations.lab.config.networking.hostName

# Expensive: full system derivation
time nix eval .#nixosConfigurations.lab.config.system.build.toplevel --raw >/dev/null

Lab 0 — Workspace

mkdir -p ~/lab/nixos/internals/evaluator
cd ~/lab/nixos/internals/evaluator

Use pure files here before editing Capstone modules.


Lab 1 — Watch memoization

Create thunks.nix:

let
  expensive = builtins.trace "EXPENSIVE" (builtins.foldl' (a: b: a + b) 0 (builtins.genList (i: i) 100));
  bag = { x = expensive; y = expensive; };
in
{
  first = bag.x;
  second = bag.y;
}
nix eval -f thunks.nix first
nix eval -f thunks.nix second
nix eval -f thunks.nix

Record how many times EXPENSIVE prints in each case. Explain sharing in one paragraph in NOTES.md.

Acceptance: You can predict print counts before running, then verify.


Lab 2 — Infinite recursion zoo

Create cycles.nix with three named attributes that each fail differently when forced:

  1. Direct let cycle
  2. Mutual a/b cycle
  3. rec set cycle

Force each with nix eval -f cycles.nix <name> and paste the error fingerprints into NOTES.md.

Then fix all three; leave the broken versions commented for teaching.

Acceptance: Three broken + three fixed forms committed; each error reproduced once.


Lab 3 — seq vs deepSeq

# force.nix
let
  t = {
    a = builtins.trace "a" 1;
    b = builtins.trace "b" (throw "b-fail");
  };
in {
  onlyWhnf = builtins.seq t "ok-whnf";
  full = builtins.deepSeq (builtins.removeAttrs t ["b"]) "ok-deep";
  # fullBoom = builtins.deepSeq t "never";
}

Evaluate onlyWhnf and full. Uncomment fullBoom once and capture the error. Write when WHNF is enough for module work.


Lab 4 — Trace a tricky real eval (your flake)

Pick one leaf from your lab host:

cd /path/to/your/flake
nix eval .#nixosConfigurations.<host>.config.networking.hostName
# or a service option you care about
time nix eval .#nixosConfigurations.<host>.config.system.build.toplevel --raw >/dev/null

If eval is slow, binary-search:

  1. Comment out recently added modules (or use a slimmed modules = [ ... ] copy).
  2. Force smaller slices with nix eval …config.services.<name>.enable.
  3. Add a temporary lib.trace only around the suspect option default.

Document the path of the investigation—even if the conclusion is “eval cost is nixpkgs baseline.”

Acceptance: Written investigation path with wall-clock numbers for leaf vs toplevel.


Lab 5 — Intentional soft failure with tryEval

# try.nix
{
  ok = builtins.tryEval (1 + 1);
  bad = builtins.tryEval (1 + "x");
}
nix eval -f try.nix --json

Note: type errors vs throw vs infinite recursion behave differently under tryEval—catalog what you observe on your Nix version.


Lab 6 — Module-shaped cycle (optional, high value)

Create a tiny lib.evalModules demo with two options whose defaults reference each other. Force eval; capture the error. Redesign so defaults form a DAG (one root option, one derived).

# module-cycle-demo.nix — sketch; expand with lib.evalModules
# Show broken defaults, then fixed defaults from a pure root path.

Acceptance: One paragraph on why “defaults depending on config” is a design smell when it re-enters the same option.


Lab 7 — Stats snapshot

NIX_SHOW_STATS=1 nix eval --impure --expr 'builtins.length (builtins.attrNames (import <nixpkgs> { }))' \
  2>&1 | tee stats-attrnames.txt

Compare (roughly) against evaluating a tiny expression. Do not over-interpret; note that large attrset demand is expensive.


Failure modes (quick reference)

Symptom Likely class First move
infinite recursion encountered Cycle Force smaller attr; draw option DAG
Eval hangs, no error Cycle or huge force Interrupt; bisect modules
Trace never prints Not forced Wrap the used path
Trace floods Mapped over huge list Narrow value; use traceValSeqN
tryEval “lies” Uncatchable error class Catalog; don’t rely for cycles
Multi-minute silence then build Eval cost time nix eval leaf vs toplevel

Common gotchas

Symptom Theory link
infinite recursion encountered with no file Cycle in options/rec; force a smaller attr
Trace never prints Expression never forced; wrap the used value
Trace prints thousands of times You forced a list map; narrow the traced value
Eval hangs, no error Likely cycle; interrupt and bisect modules
deepSeq “fixed” a bug You hid a lazy design smell; redesign dependencies
with pkgs; + name collision Shadowing; drop with while debugging
Module default uses config of same option Define default from other options or pure constants
Temporary traces left in production modules Grep before merge; CI can ban builtins.trace in modules/

Checkpoint

  • Explain thunk / WHNF / memoization in your own words
  • Three infinite-recursion examples constructed and fixed
  • seq vs deepSeq demo committed
  • Trace notes on one real flake option
  • NOTES.md with three personal gotchas
  • No permanent deepSeq left in production modules
  • Lab workspace under ~/lab/nixos/internals/evaluator

Commit

git add .
git commit -m "lab(nixos): evaluator thunks, cycles, and trace discipline"

Write three personal gotchas before continuing.


Next

Module system in depth — types, merge, priorities, and assertions that fail at eval.