Laziness, imports, and lib
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
aOnly "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 takenMental 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.niximport 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.nixPaths
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 casuallyImport 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
xlet
pkgs = import nixpkgs { inherit system overlays; };
overlays = [ (final: prev: { x = pkgs.hello; }) ]; # pkgs not ready
in
pkgsDebugging habits
- Shrink the expression
builtins.trace "here" value
- Break cycles by not referencing the final fixed point too early
- Remember: printing an attrset forces attributes (in some UIs)
- 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.libAttrsets
| 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
dataImporting 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 boomExample B — Import function
add.nix:
{ a, b }: a + bimport ./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/lazinessdata/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
cnix eval --file ./trace.nixEdit 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 xnix eval --file ./cycle.nix || trueWrite 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
- Lazy value vs import of a file
- Why multi-file beats one 2k-line expression
- One
libfunction you will actually reuse
- Why
<nixpkgs>is ambient state
Exercises
- Prove with
tracethat an unusedletbinding is not forced.
- Force a binding via string interpolation and via
builtins.length.
- Split a host model into
data.nix+logic.nix+default.nix.
- Import a directory via
default.nixfacade.
- Rebuild the shallow merge trap using
lib.recursiveUpdate.
- Write
lib.optionalAttrsequivalents by hand; then use reallib.
- Trigger and explain one infinite recursion from overlays-shaped code.
- Map a list of services into an attrset with
listToAttrs.
- Document five force points you hit while exploring.
- Compare eval time of a tiny file vs importing a heavy stub (subjective notes).
- Replace all angle-bracket mental habits with relative imports in your lab.
- List ten
lib.*functions and one-line each (search as needed).
- Commit the multi-file lab; ensure paths are file-relative.
- Preview module
imports = [ ./a.nix ./b.nix ]as the same composition idea.
- 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
libpipeline 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 |