Mika Bohinen / NixCon 2026

The thunk trap

The loop finishes before its total is computed

The thunk trap

Why your Nix loop crashes after it finishes

Mika Bohinen - NixCon 2026

A dog insists this is fine while the room burns around him.Kleisli

I wanted algebraic effects in Nix. Running those programs led from recursive calls to trampolining, strictness, defunctionalization, and an explicit machine, with another evaluator problem after each apparent repair. I assume you know the Nix language; I will introduce the compiler terminology as we encounter it.

The fold passes each returned record to the next call.

The callback, folded over ticks: 100000 inputs
Code
acc: _: { total = acc.total + 1; }
One call: the callback builds the next record
Accumulator{ total = 0; }the initial record
callback
Next accumulator{ total = acc.total + 1; }a new record
becomes acc in the next call

The fold starts from { total = 0; } and runs over ticks, a list of 100000 inputs. We name the record returned by the entire fold result. The next experiment asks what we can observe about that record.

The fold produces a record, but reading its total overflows.

result is the record returned by a fold that adds one to a total 100000 times.

Which fields are in the result?
builtins.attrNames result["total"]Recorded result
Explore code
What is the final total?
result.totalerror: stack overflow (possible infinite recursion)Recorded result
Explore code

Having the record does not mean its total has been computed.

result is the record returned by a fold that adds one to a running total 100000 times. Listing its field names succeeds. Reading its total overflows the stack. These are separate runs of the same source with the same limits.

foldl' evaluates each record, but its total can remain unevaluated.

One record per input; ticks has 100000 items
Code
result = builtins.foldl'
  (acc: _: { total = acc.total + 1; })
  { total = 0; }
  ticks;
foldl' evaluates each record; its total stays deferred
recordtotal
00
1total[0] + 1
2total[1] + 1
  • computed number
  • deferred expression
  • evaluated record
Three records shown; total[k] is the total of record k.
builtins.attrNames result["total"]Recorded result
Explore code

ticks has 100000 inputs. foldl' evaluates each returned record to weak head normal form, which does not require computing its total field.

Reading the final total follows a chain of deferred additions.

One record per input; ticks has 100000 items
Code
result = builtins.foldl'
  (acc: _: { total = acc.total + 1; })
  { total = 0; }
  ticks;
Reading the final total climbs through every deferred addition
Reading the final total climbs through every deferred additionBuilding the states returns after each step without evaluating any total. Reading the final total forces the previous total, which forces the one before it. Each forced addition waits inside the previous one until the evaluator reaches its stack limit.1234stack limittotal[n−2]total[n−1]total[n]⋯stack overflowevaluatorfold ticksread result.totalforcing depthReading the final total climbs through every deferred additionBuilding the states returns after each step without evaluating any total. Reading the final total forces the previous total, which forces the one before it. Each forced addition waits inside the previous one until the evaluator reaches its stack limit.1234stack limittotal[n−2]total[n−1]total[n]⋯stack overflowevaluatorfold ticksread result.totalforcing depth

Illustrative trace for n steps; the recorded run overflows at n = 100000.

result.totalerror: stack overflow (possible infinite recursion)Recorded result
Explore code

Each total needs the preceding total before adding one. The fold returns after every step while this dependency chain grows inside the records. Reading result.total at 100000 inputs forces the chain one level per input and overflows the stack in this recorded run.

Compute the total before returning the next record.

countStep: replacement callback for foldl'
Code
acc: _:
  let total = acc.total + 1;
  in builtins.seq total { inherit total; }
First call: compute 1 before returning the record
recordtotal
00
11
next?
  • computed number
  • deferred expression
  • evaluated record
Three records shown; total[k] is the total of record k.

countStep replaces the fold's callback and computes total before returning the record. Starting from total = 0, the first call computes 1 and stores that number in the next record.

The next call adds one to an already computed number.

countStep: replacement callback for foldl'
Code
acc: _:
  let total = acc.total + 1;
  in builtins.seq total { inherit total; }
Each call carries a computed number
recordtotal
00
11
22
  • computed number
  • deferred expression
  • evaluated record
Three records shown; total[k] is the total of record k.

The preceding record now holds 1, so the next call computes 2. Computing each total before returning its record stops the chain of deferred additions from growing.

The final total now succeeds with the same input and limits.

fixed = builtins.foldl' countStep { total = 0; } ticks
Code
acc: _:
  let total = acc.total + 1;
  in builtins.seq total { inherit total; }
Each step computes its total before the next begins
Each step computes its total before the next beginsEvery step forces its own addition, one level deep, because the previous total is already a number. Reading the final total then needs no chain of earlier additions.1234stack limittotal[n]100000⋯evaluatorfold ticksread fixed.totalforcing depthEach step computes its total before the next beginsEvery step forces its own addition, one level deep, because the previous total is already a number. Reading the final total then needs no chain of earlier additions.1234stack limittotal[n]100000⋯evaluatorfold ticksread fixed.totalforcing depth

Illustrative trace for n steps; the recorded run returns 100000.

fixed.total100000Recorded result
Explore code

Each step now performs one addition before returning, so reading fixed.total needs no chain. With 100000 inputs and the same evaluator limits it returns 100000. The change is when the total is computed.

deepSeq also fixes this counter by evaluating the record's fields.

Another replacement callback
Code
acc: _:
  let next = { total = acc.total + 1; };
  in builtins.deepSeq next next
DemandEvaluatesStill deferred
seq next nextthe recordtotal
seq next.total nexttotalnothing
deepSeq next nextevery field, including totalnothing
deepRecordFold: total from the deepSeq callback
deepRecordFold100000Recorded result
Explore code

For next = { total = acc.total + 1; }, seq next next evaluates the outer record. seq next.total next computes the scalar. deepSeq next next recursively forces its fields, including total, and also succeeds here. If we add unrelated metadata, targeted forcing can compute total without demanding that metadata.

Which expression computes total without demanding metadata?

metadata throws if it is demanded
Code
next = {
  total = 1 + 1;
  metadata = throw "not needed for counting";
};
  1. Aseq next next
  2. Bseq next.total next
  3. CdeepSeq next next

This next record has a total and unrelated metadata that throws when demanded. Which expression computes the number without demanding metadata?

seq next.total computes the number without evaluating metadata.

metadata throws if it is demanded
Code
next = {
  total = 1 + 1;
  metadata = throw "not needed for counting";
};
(builtins.seq next.total next).total2Recorded result
Explore code
(builtins.deepSeq next next).totalerror: not needed for countingRecorded result
Explore code

(builtins.seq next.total next).total returns 2 without demanding metadata. (builtins.deepSeq next next).total also reaches the throwing metadata. Printing a whole record in a REPL can itself demand fields, so we ask for the number here.

Compute the running total before returning the next record.

countStep
Code
acc: _:
  let total = acc.total + 1;
  in builtins.seq total { inherit total; }
nixcon2026.bohinen.no

Examples - captured evidence - reading - PDFs

sternenseemann: trampolining Nix (2022)

When a computation fails or retains more memory than expected, follow the values its next step needs and the references keeping earlier state alive. Those relationships explain which part of the program needs to change.

Worked example

The fold returns an attribute set. Ask for its names without demanding the value of total.

Definitions · example.nix
n = 100000;
ticks = builtins.genList (i: i) n;
result = builtins.foldl'
  (acc: _: { total = acc.total + 1; })
  { total = 0; } ticks;

Empty source

    Inspect the code, then run the expression.

    Result
    Not run
    Reference