Mika Bohinen / NixCon 2026

The thunk trap

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

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

What happens when we ask this fold for its result?

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?
Explore code
What is the final total?
result.total?
Explore code

What does this strict fold evaluate?

One record per input; ticks has 100000 items
Code
result = builtins.foldl'
  (acc: _: { total = acc.total + 1; })
  { total = 0; }
  ticks;

Empty source

    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.
    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.

    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.
    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.
    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.

    builtins.attrNames result?
    Explore code

    What does deepSeq evaluate in this counter?

    Another replacement callback
    Code
    acc: _:
      let next = { total = acc.total + 1; };
      in builtins.deepSeq next next
    deepRecordFold: total from the deepSeq callback
    deepRecordFold?
    Explore code

    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

    What do the two expressions evaluate?

    metadata throws if it is demanded
    Code
    next = {
      total = 1 + 1;
      metadata = throw "not needed for counting";
    };
    (builtins.seq next.total next).total?
    Explore code
    (builtins.deepSeq next next).total?
    Explore code
    Overview
    1. The thunk trap
    2. The fold passes each returned record to the next call.
    3. What happens when we ask this fold for its result?
    4. What does this strict fold evaluate?
    5. What happens when we read result.total?
    6. Compute the total before returning the next record.
    7. The next call adds one to an already computed number.
    8. Does computing total at each step repair the fold?
    9. What does deepSeq evaluate in this counter?
    10. Which expression computes total without demanding metadata?
    11. What do the two expressions evaluate?
    12. Compute the running total before returning the next record.

    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