Laziness, imports, and lib

Updated

July 30, 2026

Laziness, imports, and lib

Goal: Split expressions across files, understand lazy values vs eager imports, and run useful lib pipelines without memorizing the whole standard library.

Why this chapter matters

Real Nix is multi-file. People hit “infinite recursion,” mysteriously slow eval, or “why did that download?” without a model of when work happens. This chapter installs that model and introduces lib as the toolbox you will touch forever.

Ops motivation: large host configs, shared modules, and nixpkgs itself only evaluate tractably because of laziness. Knowing what forces a value is how you debug slow evals and cycles.


Theory 1 — Laziness: values are promises

Nix does not compute a binding until something needs its value.

let
  a = builtins.trace "forcing a" (1 + 1);
  b = builtins.trace "forcing b" (2 + 2);
in
  a

Only "forcing a" prints—b is never forced.

Why it matters

Benefit Example
Huge package sets Unused packages not fully evaluated
Optional config Disabled modules can avoid heavy work
Recursive structures Attrsets can refer to each other carefully

Forcing

Anything that inspects the value forces it: arithmetic, string interpolation of it, builtins.length, taking an attribute that needs it, building a derivation that lists it as input, etc.

let
  expensive = builtins.trace "boom" (import ./big.nix);
in
  if false then expensive else 0
# "boom" should not appear — branch not taken

Mental model

binding  =  thunk (unevaluated)
use in expr  →  force thunk  →  value (cached for later forces)

Theory 2 — Imports are different: loading is eager (once forced)

import ./other.nix

import reads and parses the file and evaluates the root expression of that file when the import expression is forced. People say “imports are eager” meaning: once you import, that file’s top-level expression is evaluated (subject to laziness inside that file’s bindings).

Mental model

import path
  → read file
  → parse
  → evaluate root expression of that file
  → return the resulting value (function, attrset, …)

Common patterns

# other.nix returns an attrset
import ./other.nix

# other.nix returns a function
import ./mkHost.nix { hostName = "lab"; user = "alice"; }

# relative to current file
import ./modules/ssh.nix

Paths

Prefer path literals so imports resolve relative to the file, not the process cwd:

import ./lib.nix          # good
import "./lib.nix"        # string — different rules; avoid casually

Import caching

Nix caches imports of the same path in an evaluation—knowing this helps reason about “why didn’t my edit apply?” (usually: different evaluation / flake copy / not saved).


Theory 3 — Multi-file composition patterns

Pattern A — Data module

users.nix:

{
  alice = { groups = [ "wheel" ]; };
  bob = { groups = [ ]; };
}

default.nix:

let
  users = import ./users.nix;
in {
  names = builtins.attrNames users;
  inherit users;
}

Pattern B — Function module

mkUser.nix:

{ name, groups ? [ ] }: {
  users.users.${name} = {
    isNormalUser = true;
    extraGroups = groups;
  };
}

default.nix:

import ./mkUser.nix { name = "alice"; groups = [ "wheel" ]; }

Pattern C — Directory + default.nix

Nix allows import ./dir when ./dir/default.nix exists. Useful for tiny libraries.

Pattern D — Layered host-shaped tree (preview)

host.nix
  imports data/users.nix
  imports lib/mkFirewall.nix
  returns nested config attrset

Same idea later as NixOS imports = [ … ], with module merge on top.


Theory 4 — Infinite recursion (how you will meet it)

let
  x = y;
  y = x;
in
  x
let
  pkgs = import nixpkgs { inherit system overlays; };
  overlays = [ (final: prev: { x = pkgs.hello; }) ]; # pkgs not ready
in
  pkgs

Debugging habits

  1. Shrink the expression
  2. builtins.trace "here" value
  3. Break cycles by not referencing the final fixed point too early
  4. Remember: printing an attrset forces attributes (in some UIs)
  5. Bisect files: comment imports until eval succeeds

Fixed points (recognition only)

lib.fixpoint (self: {
  a = 1;
  b = self.a + 1;
})

Cycles are intentional only via structured fixed points—not accidental let loops.


Theory 5 — lib as the standard library (taste, not catalog)

When you have nixpkgs:

lib = pkgs.lib;
# or
lib = import <nixpkgs/lib>;  # classic; flakes prefer pkgs.lib

Attrsets

Function Role
lib.mapAttrs Map over name/value
lib.filterAttrs Filter by predicate
lib.attrNames / lib.attrValues Keys / values
lib.recursiveUpdate Deep-ish merge
lib.optionalAttrs Conditionally include an attrset
lib.nameValuePair Build { name, value } for listToAttrs

Lists

Function Role
lib.concatLists Flatten one level
lib.unique Dedup
lib.toList Wrap non-list
lib.optional [ x ] or [] from bool
lib.optionals list or [] from bool
lib.flatten Deeper flatten

Strings

Function Role
lib.concatStringsSep Join
lib.hasPrefix / hasSuffix Tests
lib.toLower Case
lib.splitString Split

Example pipeline — users list → attrset

{ lib }:
let
  userList = [
    { name = "alice"; admin = true; }
    { name = "bob"; admin = false; }
  ];
in
  lib.listToAttrs (
    map (u: {
      name = u.name;
      value = {
        isNormalUser = true;
        extraGroups = lib.optional u.admin "wheel";
      };
    }) userList
  )

Why not memorize all of lib?

Learn 10–15 functions deeply. Search nixpkgs/lib when you need more. Module authors live in lib.mkOption, lib.mkIf, lib.types.* (host modules chapters).


Theory 6 — NIX_PATH and angle-bracket paths (literacy)

import <nixpkgs> { }

<nixpkgs> looks up NIX_PATH. This book’s default is flakes—not channels—but you must recognize angle brackets in docs.

Style Pinning
<nixpkgs> Ambient, drift-prone
import ./pinned.nix File-relative
Flake inputs.nixpkgs Lock file (preferred)

Labs in this chapter should use relative imports between your files; if you pull nixpkgs, prefer a flake or a known pin over bare <nixpkgs> when you can.


Theory 7 — Evaluation vs runtime (again, file-centric)

let
  data = import ./data.nix;          # eval time: load nix
  # building a package would realize store paths — the derivations chapter
in
  data

Importing a Nix file does not compile C. It only produces Nix values (which might describe builds).

When does “work” happen?

Action Phase
import ./x.nix forced Eval
lib.mapAttrs … Eval
mkDerivation resulting build Realize
fetchurl network Realize (FOD)

Theory 8 — Project layout habits

Habit Why
One concern per file Diffs and reuse
default.nix as facade Clean imports
Data vs functions separated Test data without logic
Avoid 2k-line single files Reviewability

Later flake layouts expand this (Home and flake layout part).


Worked examples bank

Example A — Lazy skip

let
  boom = builtins.abort "should not run";
in
  if true then 1 else boom

Example B — Import function

add.nix:

{ a, b }: a + b
import ./add.nix { a = 2; b = 40; }

Example C — lib.optional / optionals

lib.optional true "wheel"          # [ "wheel" ]
lib.optional false "wheel"         # [ ]
lib.optionals true [ "a" "b" ]     # [ "a" "b" ]

Example D — mapAttrs

lib.mapAttrs (name: cfg: cfg // { inherit name; }) {
  alice = { uid = 1000; };
  bob = { uid = 1001; };
}

Example E — recursiveUpdate vs //

lib.recursiveUpdate
  { services.foo = { enable = true; port = 80; }; }
  { services.foo = { port = 8080; }; }
# enable preserved (deep merge behavior of recursiveUpdate)

Compare to shallow // from the attrsets chapter.


Lab 1 — Multi-file project skeleton

mkdir -p ~/lab/nixos-book/concepts/laziness/{lib,data}
cd ~/lab/nixos-book/concepts/laziness

data/users.nix:

[
  { name = "alice"; admin = true;  shell = "bash"; }
  { name = "bob";   admin = false; shell = "zsh"; }
  { name = "carol"; admin = true;  shell = "bash"; }
]

lib/users.nix:

{ lib }:
userList:
lib.listToAttrs (
  map (u: {
    name = u.name;
    value = {
      isNormalUser = true;
      extraGroups = lib.optional u.admin "wheel";
      shellName = u.shell;
    };
  }) userList
)

default.nix (standalone lib stub so you need no nixpkgs for this lab):

let
  lib = {
    optional = cond: x: if cond then [ x ] else [ ];
    listToAttrs = builtins.listToAttrs;
  };
  userList = import ./data/users.nix;
  toUsers = import ./lib/users.nix { inherit lib; };
  users = toUsers userList;
in {
  inherit users;
  admins =
    map (u: u.name) (builtins.filter (u: u.admin) userList);
}
nix eval --file ./default.nix
nix eval --file ./default.nix --apply 'v: v.admins'

Lab 2 — Trace forcing

Create trace.nix:

let
  a = builtins.trace "force-a" 1;
  b = builtins.trace "force-b" 2;
  c = a + 10;
in
  c
nix eval --file ./trace.nix

Edit so the result uses b instead; confirm traces change. Record observations.


Lab 3 — Infinite recursion (safe sandbox)

# cycle.nix — expected to fail
let x = y; y = x; in x
nix eval --file ./cycle.nix || true

Write a one-line description of the error. Then fix a near-cycle by breaking the dependency.


Lab 4 — Real lib if nixpkgs available

nix repl
:lf nixpkgs
lib = pkgs.lib
lib.id 42
lib.concatStringsSep "," [ "a" "b" "c" ]
lib.filterAttrs (n: v: v > 1) { a = 1; b = 2; c = 3; }
lib.recursiveUpdate { a.b = 1; } { a.c = 2; }

Port Lab 1’s toUsers to use pkgs.lib instead of the stub (optional stretch).


Lab 5 — Teach-back

  1. Lazy value vs import of a file
  2. Why multi-file beats one 2k-line expression
  3. One lib function you will actually reuse
  4. Why <nixpkgs> is ambient state

Exercises

  1. Prove with trace that an unused let binding is not forced.
  2. Force a binding via string interpolation and via builtins.length.
  3. Split a host model into data.nix + logic.nix + default.nix.
  4. Import a directory via default.nix facade.
  5. Rebuild the shallow merge trap using lib.recursiveUpdate.
  6. Write lib.optionalAttrs equivalents by hand; then use real lib.
  7. Trigger and explain one infinite recursion from overlays-shaped code.
  8. Map a list of services into an attrset with listToAttrs.
  9. Document five force points you hit while exploring.
  10. Compare eval time of a tiny file vs importing a heavy stub (subjective notes).
  11. Replace all angle-bracket mental habits with relative imports in your lab.
  12. List ten lib.* functions and one-line each (search as needed).
  13. Commit the multi-file lab; ensure paths are file-relative.
  14. Preview module imports = [ ./a.nix ./b.nix ] as the same composition idea.
  15. Explain why disabled flakes inputs still may or may not evaluate (depends on forcing).

Common pitfalls

Symptom Theory
Infinite recursion Cycle in let/fixed-point
Import path not found Relative to file vs cwd
Unexpected work during eval Something forced a heavy binding
cannot coerce a function Forgot to call imported function
Angle-bracket surprise versions Unpinned NIX_PATH
lib undefined Never imported pkgs/lib
Edit not picked up Evaluating different file/copy
// where recursive merge needed Wrong merge tool

Checkpoint

  • Split data + logic across ≥2 files
  • Explained laziness with a trace experiment
  • Imported a function file and called it
  • Used map/filter or lib pipeline on users
  • Documented one recursion footgun
  • Prefer relative imports over <nixpkgs> in new work

Further depth (this book)

Topic Chapter / part
Derivations (when eval becomes build) Derivations as an idea
Flake multi-file projects Flakes and project devShells
Module imports + mkIf Host modules chapters
Evaluator performance Part Internals
Shared module libraries Part Ops and fleet