Day 5 — Laziness, imports & lib

Updated

July 30, 2026

Day 5 — Laziness, imports & lib

Stage I · ~4h (theory-heavy)
Goal: Split expressions across files, understand lazy values vs eager imports, and run one useful lib pipeline without memorizing the whole standard library.

Why this day exists

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


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

Theory 2 — Imports are different: loading is eager

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

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.


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)

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

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

Strings

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

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
  )

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

import <nixpkgs> { }

<nixpkgs> looks up NIX_PATH. This volume’s default later 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)

Today’s labs 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 — Day 6
in
  data

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


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; };
}

Lab 1 — Multi-file project skeleton

mkdir -p ~/lab/90daysofx/02-nixos/day05/{lib,data}
cd ~/lab/90daysofx/02-nixos/day05

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";
      # shell left as string stand-in
      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

Common gotchas

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

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
  • Notes committed

Commit

cd ~/lab/90daysofx/02-nixos/day05
git init 2>/dev/null || true
git add .
git commit -m "day05: laziness imports lib pipelines"

Tomorrow

Day 6 — Derivations as idea. Evaluation produces build plans (.drv); realization fills /nix/store. You will nix build something tiny and read nix log.