Nix Evaluation Model
Prerequisites
- Nix — the system overview (three layers, core insight)
- Nix Language — syntax (attribute sets, functions, let/in, import)
- Nix Store and Derivations — what evaluation produces
This note explains how the Nix evaluator executes expressions — the mechanics that make laziness, closures, and the fixed-point package set work. The Nix Language note covers syntax; this note covers semantics.
What evaluation produces
Evaluation can produce any Nix value — a string, a number, a list, a function, a plain attrset. The evaluator doesn’t know or care about “packages” or “building.” It’s just reducing expressions to values.
nix eval --expr '1 + 1' # → 2 (a number, no derivation involved)
nix eval --expr '"hello"' # → "hello" (a string)
nix eval --expr '{ x = 1; }' # → { x = 1; } (a plain attrset)Derivations are one specific kind of value — a special attrset that acts as the bridge between evaluation (pure computation) and building (side effects). When you force pkgs.hello, the evaluator returns an attrset like:
{
type = "derivation"; # this attribute marks it as special
name = "hello-2.12";
drvPath = "/nix/store/abc...-hello-2.12.drv"; # the build plan file
outPath = "/nix/store/xyz...-hello-2.12"; # where outputs WILL live
# ... plus build instructions, dependencies, etc.
}This is still pure data — no compiler has run, no files have been downloaded. The evaluator’s job is done. What happens next depends on what you asked for:
nix eval→ shows you the value. If it’s a derivation, it prints the attrset. Nothing is built.nix build→ sees the result is a derivation, then hands it to the build system:- Instantiation → writes the
.drvfile to the store (serializes the recipe) - Building (see Nix Store and Derivations) → executes the recipe in a sandbox, producing actual binaries
- Instantiation → writes the
The confusion arises because “derivation” sounds like it should mean “the built thing.” It doesn’t — it means “the plan for building the thing.” The built thing is called the output or store path. And many Nix evaluations never produce a derivation at all — they just compute plain values.
You don’t construct derivations by hand. You can’t just write { type = "derivation"; name = "hello"; } and expect Nix to build it — the attrset needs specific internal attributes (drvPath, outPath, builder references) that only the evaluator generates. Instead you call helper functions:
builtins.derivation { name, system, builder, ... }— the primitive. Produces a valid derivation attrset, but gives you nothing: no compiler, no coreutils, no$PATH. You must provide everything explicitly.stdenv.mkDerivation { pname, version, src, ... }— the nixpkgs wrapper (not a builtin). Callsbuiltins.derivationinternally but sets up a full build environment with bash, gcc, coreutils, and a phase system (unpackPhase→buildPhase→installPhase). This is what 99% of packages use.
See builtins.derivation vs stdenv.mkDerivation for the full comparison.
Lazy (demand-driven) evaluation
Nix uses call-by-need evaluation: an expression is never evaluated until its value is required, and once evaluated the result is cached in place of the original thunk (an unevaluated closure — see below). Values that are never needed are never computed.
This is what makes import nixpkgs {} practical — nixpkgs defines ~100,000 packages, but importing it only creates ~100,000 thunks. The evaluator never descends into a package definition until you actually reference it.
Step-by-step: nix build .#packages.x86_64-linux.hello
1. File read and parse. The CLI resolves flake.nix, reads it as bytes, parses it into an AST (abstract syntax tree). Nothing is evaluated yet — every AST node is wrapped as a thunk.
2. First forcing event. The CLI needs to inspect the flake’s outputs attribute. It asks the evaluator to force the root thunk. The root expression is an attribute set literal:
{
inputs = { ... };
outputs = { self, nixpkgs, ... }: { ... };
}Forcing it produces a WHNF (weak head normal form — explained below) attrset node whose values are still thunks.
3. Lazy attribute-path traversal. The CLI traverses packages.x86_64-linux.hello one segment at a time:
Green nodes are forced one at a time along the requested path. Grey nodes (siblings) are never evaluated — they stay as thunks and vanish when the process exits.
At each step the evaluator forces exactly one thunk. It never descends into siblings.
4. Unrequested attributes. gcc, python3, and every other sibling remain as unevaluated thunks. When the evaluator process exits, those thunks vanish with the process heap — no special GC is needed.
Thunks and forcing
What a thunk is
A thunk is a closure pairing an unevaluated expression with the lexical environment in which it was created:
Thunk = (AST node, Environment)
When a thunk is forced, the expression is evaluated in its captured environment, and the thunk is replaced in memory by the resulting value. All subsequent references to the same thunk observe the cached value — no re-evaluation occurs. This is call-by-need, and it means thunks are memoized by construction.
WHNF vs normal form
- WHNF (weak head normal form): The outermost constructor is evaluated, but inner values may still be thunks. Forcing an attrset to WHNF means you know it is an attrset and you can see its keys, but the values behind those keys are still lazy.
- Normal form: Everything is fully evaluated, recursively. Achieved only by
builtins.deepSeqor by demand propagating through every sub-expression.
Most operations force to WHNF only — just enough to proceed.
When is a thunk forced?
| Operation | What gets forced | Depth |
|---|---|---|
Attribute access x.a | x (to verify it’s an attrset) | WHNF only — .a stays lazy |
String interpolation "${x}" | x (to a string-coercible value) | WHNF |
Arithmetic x + 1 | x (to a number) | WHNF |
Boolean test if x then ... | x (to a bool) | WHNF |
Function application f x | f (to a lambda) | WHNF — x stays lazy |
Attrset pattern { a, b }: ... | argument (to WHNF attrset); a and b forced only when used inside the body | WHNF of the argument |
builtins.seq e1 e2 | e1 to WHNF, then returns e2 | WHNF only |
builtins.deepSeq e1 e2 | e1 fully (recursively), then returns e2 | Normal form |
builtins.seq vs builtins.deepSeq
builtins.seq e1 e2 # Forces e1 to WHNF, then returns e2
builtins.deepSeq e1 e2 # Forces e1 to full normal form (all nested thunks),
# then returns e2seq is sufficient to check that e1 is not bottom (i.e., to surface an error early). deepSeq walks the entire value tree forcing every nested thunk. It is expensive and should only be used when you need to guarantee full evaluation — for example, verifying a config tree has no errors:
# Force the entire NixOS config to flush out any evaluation errors
builtins.deepSeq config.system.build.toplevel configSharing and memoization
Because forcing replaces the thunk in-place, sharing is preserved. If two attributes both point to the same thunk (via a let binding or the fixed-point), forcing one forces both — they share the same thunk node. This prevents exponential blowup in dependency graphs.
import semantics
What import does
import is a built-in function that takes a path and returns the value of the top-level expression in that file:
import ./foo.nix
# Reads foo.nix, parses it, evaluates its top-level expression, returns the result.The file is evaluated in a fresh lexical scope. Whatever the expression evaluates to — an attrset, a function, a string — is returned as-is.
Import caching
Within a single evaluator invocation, importing the same path twice returns the same cached value. The evaluator maintains an internal cache keyed on the resolved store path. The second import <nixpkgs> in the same evaluation does not re-parse or re-evaluate the file.
This matters for large imports: a flake that references nixpkgs in twenty let bindings does not evaluate nixpkgs/default.nix twenty times.
Binding, not assignment
Nix has no assignment statement. There is no mutation. There is only binding — a name is permanently associated with a value within a scope.
let
pkgs = import nixpkgs { system = "x86_64-linux"; };
in
pkgs.helloThe name pkgs does not exist before the let, cannot be re-bound inside the let, and ceases to exist after the in expression. This is identical to lambda-calculus binding. The syntax looks like imperative assignment — it is not.
When you write pkgs = import nixpkgs { ... }; inside an attribute set, you are declaring an attribute whose value is the (lazily evaluated) result of that expression. It is not “inserted into a scope” — it is the attribute value, bound for the lifetime of that attrset.
Files returning functions — the standard pattern
Two shapes a .nix file can have
Shape A — returns an attrset directly:
# config.nix
{
user = "alice";
shell = "/bin/bash";
}import ./config.nix immediately returns the attrset. No further application needed.
Shape B — returns a function (the standard nixpkgs pattern):
# hello/default.nix
{ lib, stdenv, fetchurl }:
stdenv.mkDerivation {
pname = "hello";
version = "2.12";
src = fetchurl { ... };
meta.license = lib.licenses.gpl3Plus;
}import ./hello/default.nix returns a function value, not a derivation. The derivation only appears after you call the function with the right arguments.
Why packages are functions: dependency injection
A package definition needs stdenv, lib, fetchurl, and potentially dozens of other dependencies. If the file evaluated to a concrete derivation immediately, those identifiers would need to be in scope at import time — either as globals (fragile, impure) or redefined in every file (verbose, inconsistent).
Instead, the convention is: the file declares what it needs as function parameters; the caller supplies them. The caller in nixpkgs is callPackage:
# Inside nixpkgs all-packages.nix (simplified)
hello = callPackage ../applications/misc/hello { };callPackage introspects the function’s argument names using builtins.functionArgs, then automatically plucks the matching attributes from pkgs and passes them. This auto-wiring is what makes the 100,000-package collection manageable without explicit dependency threading.
How import nixpkgs {} works — full chain
pkgs = import nixpkgs { system = "x86_64-linux"; };Here nixpkgs is a flake input resolved to a store path like /nix/store/...-source.
Step 1 — import nixpkgs. The evaluator finds <store-path>/default.nix, reads and parses it. The file’s top-level expression is a function:
# nixpkgs/default.nix (simplified)
{ system ? builtins.currentSystem
, config ? {}
, overlays ? []
, crossSystem ? null
} @ args:
import ./pkgs/top-level/all-packages.nix { ... }import nixpkgs returns this function value — nothing inside the body has been evaluated.
Step 2 — { system = "x86_64-linux"; }. The attrset is applied to the function. The evaluator pattern-matches:
system→"x86_64-linux"(provided)config→{}(default)overlays→[](default)crossSystem→null(default)
If you had passed {} with no system, the default builtins.currentSystem would be used (evaluated at call time by querying the Nix daemon’s platform string).
Step 3 — The fixed-point. The function body evaluates all-packages.nix, which uses a fixed-point combinator (lib.fix or lib.extends) to build a self-referential attrset:
# Conceptually
let
pkgs = lib.fix (self: {
hello = callPackage ./hello { };
gcc = callPackage ./gcc { stdenv = self.stdenv; };
stdenv = ...;
...
});
in pkgslib.fix is defined as f: let x = f x; in x — a circular binding that works because of laziness. The function f receives self (which is the final result x), but never forces self eagerly — it only closes over it. Each attribute starts as a thunk that references self; forcing hello calls callPackage, which accesses self.stdenv, which forces that thunk, and so on. No infinite loop because each chain bottoms out at a concrete value.
Step 4 — The result. The function returns the pkgs attrset. This is what gets bound to your pkgs name. None of the individual package derivations have been evaluated at this point — they are all still thunks.
Scope, closures, and circular references
Lexical scope
Nix is lexically scoped: the meaning of every identifier is determined by where the expression is written in source, not by where it is called. A name resolves to its closest enclosing binding.
Closures
When a function is defined inside a let or another function, it captures references to all names visible at that point:
let
prefix = "hello-";
mkName = version: prefix + version; # closes over `prefix`
in
mkName "2.12" # -> "hello-2.12"The evaluator finds prefix via the closure’s captured environment, not through any dynamic lookup.
rec {} — recursive attribute sets
rec { ... } extends the attrset’s own scope so that definitions can reference siblings:
rec {
major = 2;
minor = 12;
version = "${toString major}.${toString minor}";
}Mechanically, rec is sugar for a let that binds all attributes and then builds an attrset from them. The desugared form is approximately:
let major = 2; minor = 12; version = ...; in
{ inherit major minor version; }Because bindings are lazy, each starts as a thunk. Forcing version forces major and minor; if you never access version, those thunks may never be forced.
Circular references — when they work, when they don’t
Productive circularity (works):
lib.fix (self: {
a = self.b + 1;
b = 10;
})
# self.a forces self.b → 10 → a = 11. No infinite loop.The circularity is real in the value graph, but each step makes progress — forcing a forces b, which is a concrete value.
Divergent circularity (crashes):
rec { a = a + 1; } # forcing a forces a → infinite recursion
let x = x; in x # same: forcing x forces xNix detects this at runtime: the evaluator marks thunks as being evaluated (a “black hole”) when it starts forcing them. If it encounters a thunk already marked, it throws error: infinite recursion encountered rather than looping forever.
The rule: Circular bindings are fine as long as the evaluation of each thunk bottoms out at a concrete value without needing to re-enter itself. The fixed-point pattern (lib.fix) is the canonical example of productive circularity.
See also
- Nix Language — syntax reference (attribute sets, functions, strings, paths)
- Nix Store and Derivations — what evaluation produces and where outputs live
- nixpkgs — the package set that uses
callPackageandlib.fix - Nix Flakes — how flakes structure the entry point that the evaluator traverses