Nix Evaluation Model

Prerequisites

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:
    1. Instantiation → writes the .drv file to the store (serializes the recipe)
    2. Building (see Nix Store and Derivations) → executes the recipe in a sandbox, producing actual binaries

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). Calls builtins.derivation internally but sets up a full build environment with bash, gcc, coreutils, and a phase system (unpackPhasebuildPhaseinstallPhase). 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.deepSeq or by demand propagating through every sub-expression.

Most operations force to WHNF only — just enough to proceed.

When is a thunk forced?

OperationWhat gets forcedDepth
Attribute access x.ax (to verify it’s an attrset)WHNF only — .a stays lazy
String interpolation "${x}"x (to a string-coercible value)WHNF
Arithmetic x + 1x (to a number)WHNF
Boolean test if x then ...x (to a bool)WHNF
Function application f xf (to a lambda)WHNF — x stays lazy
Attrset pattern { a, b }: ...argument (to WHNF attrset); a and b forced only when used inside the bodyWHNF of the argument
builtins.seq e1 e2e1 to WHNF, then returns e2WHNF only
builtins.deepSeq e1 e2e1 fully (recursively), then returns e2Normal 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 e2

seq 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 config

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

The 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 pkgs

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

Nix 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 callPackage and lib.fix
  • Nix Flakes — how flakes structure the entry point that the evaluator traverses