The evaluator
The evaluator
Goal: Explain thunks, force evaluation intentionally, reproduce and fix infinite recursion, and use trace / tryEval / seq / deepSeq without cargo-culting them.
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.aYou 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 encounteredPattern B — Mutual recursion without a base case
let
a = b + 1;
b = a + 1;
in aPattern C — Attrset self-reference (legal shape, dangerous force)
rec {
a = 1;
b = a + 1; # OK
# c = c; # infinite if forced
}rec is not evil; forcing a cycle is.
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?
helloPrefer 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" valuePrints 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: latelib.debug.traceVal / traceSeq / traceValSeq (nixpkgs)
When you have lib from nixpkgs:
lib.debug.traceVal someAttr
lib.debug.traceValSeqN 2 nestedPrefer 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.hostNameFor 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 |
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 encounteredFixed
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/nullLab 0 — Workspace
mkdir -p ~/lab/nixos/internals/evaluator
cd ~/lab/nixos/internals/evaluatorUse 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.nixRecord 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:
- Direct
letcycle
- Mutual
a/bcycle
recset 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/nullIf eval is slow, binary-search:
- Comment out recently added modules (or use a slimmed
modules = [ ... ]copy).
- Force smaller slices with
nix eval …config.services.<name>.enable.
- Add a temporary
lib.traceonly 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 --jsonNote: 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.txtCompare (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
seqvsdeepSeqdemo committed
- Trace notes on one real flake option
NOTES.mdwith three personal gotchas
- No permanent
deepSeqleft 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.