Nix Expressions and Evaluation

Updated

September 12, 2026

Nix Expressions and Evaluation

Nix does not run a list of statements. It evaluates one pure expression. The boring default is: a file is a single value, the same inputs always yield the same AST, and laziness means unused branches (including throw) never run.

This part uses the Nix 2.35 CLI (nix eval). Flakes come in part 3–4; here the language is enough.

Mental model

Bash mutates the world from top to bottom. Nix does not:

  1. One expression per file. 2 + 2, { a = 1; }, and a 400-line package are the same kind of thing.
  2. Deterministic. Same tree, same result. No clock, no $HOME, no “it depends who ran it.”
  3. Lazy. Defining fifty attributes and asking for .port does not compute the other forty-nine.
Bash:   step → mutate → step → mutate → leftover state
Nix:    root expression → demand-driven subexpressions → a value

nix eval --expr is a scratchpad. nix eval --file evaluates a file. --json is for machines.

Worked examples

Case 1: Arithmetic is an expression

nix eval --expr '1 + 2 * 3'

Output:

7

Precedence is ordinary. There is no print. The value is the result.

Case 2: A file is one let … in

Save as root_expression.nix:

# root_expression.nix
let
  deskId = "terminal-01";
  status = "online";
in
{
  id = deskId;
  state = status;
  ready = true;
}
nix eval --file root_expression.nix --json

Output:

{"id":"terminal-01","ready":true,"state":"online"}

--json sorts keys. The file did not “run” three assignments; it produced one attrset.

Case 3: Laziness skips throw

Save as laziness_proof.nix:

# laziness_proof.nix
let
  validField = "desk-service";
  brokenField = throw "This error is never triggered!";
in
{
  service = validField;
  broken = brokenField;
}
nix eval --file laziness_proof.nix --apply 'x: x.service'

Output:

"desk-service"

broken exists on the set. It is not forced. Force it:

nix eval --file laziness_proof.nix --apply 'x: x.broken'

Output (shape):

error: This error is never triggered!

That error is the proof: evaluation is demand-driven.

Case 4: --strict / JSON vs thunks

nix eval --expr '{ a = 1 + 1; b = throw "no"; }' --apply 'x: x.a'

Output:

2

nix-instantiate --eval without --strict can print thunks as <CODE>. Prefer nix eval (2.35) plus --json or --apply so you see values, not internals.

Case 5: No side effects at eval time

Save as no_echo.nix:

# no_echo.nix
let
  # This does not print. It is a string.
  msg = "desk online";
in
msg
nix eval --file no_echo.nix

Output:

"desk online"

There is no echo during eval. Writes happen later, in a derivation sandbox (fundamentals / packaging). If you wanted a file on disk, you have not evaluated yet — you have not built.

throw "msg" is a lazy error (only if demanded). abort "msg" is immediate. Use throw in unused branches; abort when the file must not even load. Nix 2.35 can drop you into a debugger:

nix eval --file laziness_proof.nix --apply 'x: x.broken' --debugger

:backtrace then. Do not leave builtins.trace in production modules after you found the bug.

The trap

The trap is expecting echo or curl inside a .nix file to run when you nix eval. Eval is memory. Network and files belong in FOD fetchers and builders.

A second trap: two expressions in one file with no let. That is a parse error. One root.

The boring rule

  • One expression per file.
  • nix eval --expr / --file on Nix 2.35. --json when you want stable output.
  • Laziness: unused throw is silent; accessed throw is an eval error. abort always fires.
  • --debugger on 2.35 for eval traces. No leftover builtins.trace on main.
  • No side effects at eval. Builds write $out.
  • --apply to pick a field without evaluating the whole set.

Try this

  1. Case 3: eval .service then .broken. Predict before you run.
  2. nix eval --expr 'let x = 1 / 0; in 4' — does it error? (Integer division by zero is forced only if x is used.)
  3. Put two attrsets in one file without let. Read the parse error.
  4. nix eval --expr '{ a = 1; }' --json and compare to --impure — you should not need --impure here.