nix-playgraound/notes/002-nix-primer.md
Hassan Abedi d2814acdc9 WIP
2026-04-15 13:44:59 +02:00

7.2 KiB

Nix Primer

Nix is two things bundled:

  1. A pure, lazy functional language for describing builds.
  2. A package manager that takes those descriptions and produces content-addressed store paths.

Confusingly, both are called "Nix." Separate them in your head and everything gets easier.


1. The Mental Model: Eval, Then Build

Every nix build or nix develop runs in two phases:

.nix source  ──(evaluate)──▶  .drv file  ──(realise)──▶  /nix/store/...-output
       │                         │                         │
       └── Nix language          └── Recipe                └── Actual files
           (pure, lazy)              (on disk)                 (content-addressed)
  • Evaluation runs the Nix language. Pure, in-memory, fast. Produces .drv files.
  • Realisation actually builds things. Sandboxed. Slow. Outputs go to /nix/store.

You can evaluate without building (nix eval, nix-instantiate) and you can build without touching the language if you already have a .drv. Most errors you'll see are eval errors: typos in attrsets, missing arguments, and undefined names.


2. The Language in 5 Minutes

Primitives and Collections

42                           # integer
3.14                         # float
"hello ${name}"              # string with interpolation
''multi-line                 # indented string (strips common leading whitespace)
  string''
true false null
[ 1 2 3 ]                    # list (space-separated, NOT comma)
{ a = 1; b = "two"; }        # attrset
/foo/bar                     # path (literal, not a string!)
./relative/path              # path relative to current file

Paths are their own type. When used, they're copied into the store and replaced with the store path.

Functions

Single-argument, curried:

add = x: y: x + y;
add 2 3                      # => 5

Attrset destructuring (how "named args" work):

mkUser = { name, age ? 0, ... }: { inherit name age; };
mkUser { name = "ada"; }     # => { name = "ada"; age = 0; }

The ... means "ignore extra attrs." Without it, passing extras is an error.

Bindings

let
  x = 1;
  y = x + 1;        # can reference earlier bindings (and each other: lazy)
in x + y
{ inherit x y; }    # ≡ { x = x; y = y; }
{ inherit (pkgs) hello jq; }   # pulls hello/jq from pkgs into this attrset

with (Use Cautiously)

with pkgs; [ hello jq ripgrep ]
# ≡
[ pkgs.hello pkgs.jq pkgs.ripgrep ]

Convenient in package lists. Avoid at the top of a file: it silently shadows names and makes debugging hard.

Laziness

Nothing is evaluated until needed. This is why you can have large attrsets like nixpkgs (100k+ packages) and only pay for what you use.

let broken = throw "nope"; ok = 1; in ok   # => 1, never evaluates `broken`

import

import ./foo.nix               # evaluate foo.nix, return its value
import ./foo.nix { x = 1; }    # if foo.nix is a function, also call it

3. Derivations: The Core Primitive

A derivation is "a build, described as data." You create one with derivation { … } (low-level) or pkgs.stdenv.mkDerivation { … } (the nixpkgs wrapper you'll actually use).

Minimal example:

{ pkgs ? import <nixpkgs> {} }:

pkgs.stdenv.mkDerivation {
  pname = "hello-note";
  version = "0.1";
  src = ./.;                          # path: gets copied into the store
  installPhase = ''
    mkdir -p $out/bin
    echo '#!/bin/sh'            > $out/bin/hello-note
    echo 'echo hello from nix' >> $out/bin/hello-note
    chmod +x $out/bin/hello-note
  '';
}

Key ideas:

  • $out is the output store path the build writes into.
  • Phases (unpackPhase, buildPhase, installPhase, …) are shell snippets stdenv runs in order. Override the ones you need.
  • No network in the build sandbox, except for fixed-output derivations (whose hash you declare up front).
  • Every input (source, compiler, env vars) becomes part of the hash. Change anything and you get a fresh store path.

4. Store Model: Why This Is All Different

Traditional package managers put files in /usr/bin, /usr/lib, and similar shared locations. One version at a time. Upgrades mutate shared state.

Nix puts every build in /nix/store/<hash>-<name>/ with the hash derived from all its inputs (recursively). That gives you:

  • Atomic upgrades and rollbacks: switching "versions" is just switching symlinks.
  • Multiple versions coexisting: they live at different hashes.
  • Reproducibility: same inputs produce the same hash and the same output (in theory; in practice, 99% there).
  • Binary caching: if someone else already built exactly these inputs, you can download their output. This is what cache.nixos.org does.
  • Garbage-collectable: anything not referenced by a "GC root" can be deleted safely.

5. How You'll Actually Use Nix Day-to-Day

Ordered roughly by power-to-complexity:

  1. nix run nixpkgs#cowsay -- moo: run a package without installing.
  2. nix shell nixpkgs#jq nixpkgs#ripgrep: throwaway shell with those tools.
  3. Dev shell via flake.nix: per-project pinned toolchain. This is the most common thing flakes are used for.
  4. nix build: produce artifacts (binaries, containers, and ISOs).
  5. NixOS: declare your whole OS config (packages, services, users, and network) in Nix.
  6. home-manager: same idea, at the user level.

Each step up locks in more of your environment. You don't need to adopt all of it at once.


6. Minimum Useful Commands

# Language / introspection
nix repl                               # REPL. Try `:l <nixpkgs>` then `hello`.
nix eval --expr '1 + 1'
nix-instantiate --eval -E '{ a = 1; }.a'

# Running & building
nix run nixpkgs#hello
nix shell nixpkgs#jq
nix build nixpkgs#hello                # writes ./result symlink
nix-store --query --references ./result   # show closure

# Housekeeping
nix store gc                           # garbage collect
nix store optimise                     # dedupe identical files via hardlinks
nix why-depends ./result nixpkgs#glibc # trace dependency chains

7. Gotchas That Trip Up Newcomers

  • Lists use spaces, not commas. [ 1 2 3 ]. Commas are a syntax error.
  • rec { } makes an attrset self-referential. Without rec, attrs can't reference each other. You'll see this in package definitions.
  • Paths vs. strings. ./foo is a path (copied to store on use). "./foo" is a string (not auto-copied). Mixing them causes surprises.
  • builtins.* is the always-available low-level namespace. lib.* (from nixpkgs) is the high-level helper library. Both exist; they're different.
  • Evaluation errors are lazy. An error deep in an unused branch only fires when you actually use it. nix-instantiate --eval --strict forces full evaluation.
  • Hash errors on first build of an FOD: you'll see got: sha256-…. Copy that hash into your expression. This is expected workflow.

8. What to Read or Try Next

  • Open nix repl, type :l <nixpkgs>, then hello, hello.drv, hello.outPath. Poke around.
  • Read nix.dev, the current best introduction.
  • See 003-flakes.md for the flakes-specific layer on top of all of this.