Why this matters
Every flake, every package, every home-manager config is an attribute set. //, rec, with and inherit appear in all of them. Two of these have behaviour that looks like a bug the first time you meet it: // does not merge nested sets, and with loses to any name already in scope.
Concept
Attribute sets
Curly braces, name = value; pairs, semicolons mandatory, order irrelevant. Nested access with dots. a.b.c = 1; is shorthand for nesting:
{ a.b.c = 1; }{ a = { b = { c = 1; }; }; }Keys can be any string, quoted when not an identifier, or computed with ${}:
let k = "dyn"; in { ${k} = 1; "with space" = 2; }{ dyn = 1; "with space" = 2; }Defining a key twice is an error, not a last-wins override. Accessing a missing key is an error. Test first with ?, or fall back with or:
[ ({ a = 1; } ? b) ({ a = 1; }.b or "default") ][ false "default" ]// merges one level only
The update operator takes keys from the right side, overriding the left:
{ a = 1; b = 2; } // { b = 3; }{ a = 1; b = 3; }It is shallow. A nested set on the right replaces the whole nested set on the left:
{ x = { a = 1; b = 2; }; } // { x = { c = 3; }; }{ x = { c = 3; }; }a and b are gone. For deep merge use lib.recursiveUpdate (see Builtins and lib), which gives { x = { a = 1; b = 2; c = 3; }; }. The module system has its own merge rules; neither is //.
rec: self-reference
Plain attrsets cannot see their own keys. { a = 1; b = a + 1; } fails:
error: undefined variable 'a'
at «string»:1:14:
1| { a = 1; b = a + 1; }
| ^rec allows it:
rec { a = 1; b = a + 1; }{ a = 1; b = 2; }rec plus a cycle is infinite recursion encountered. Prefer let for intermediate values; rec is mostly seen in older package definitions.
let ... in
Local bindings, visible in the body and in each other (order-free, recursive by default):
let a = 1; b = a + 1; in b * 1020Inner let shadows outer. let x = 1; in let x = 2; in x is 2.
inherit: copy a binding into a set
inherit a b; means a = a; b = b;. inherit (set) a b; means a = set.a; b = set.b;:
let
pkgs = { nodejs = "node"; pnpm = "pnpm"; biome = "biome"; };
in
{ inherit (pkgs) nodejs pnpm; }{ nodejs = "node"; pnpm = "pnpm"; }This is the idiom you will see at the top of every flake output: inherit (pkgs) lib;. Also works inside let: let inherit (pkgs) lib; in ....
with: bring all keys into scope
with s; body makes every key of s a variable in body:
let s = { a = 1; b = 2; }; in with s; a + b3The scoping rule: names from with have the lowest priority. Any let, function argument, or outer binding wins:
let a = 100; s = { a = 1; }; in with s; a100Not 1. The with did not shadow a; it was shadowed by it. Reversed nesting gives the same answer: with { a = 1; }; let a = 100; in a is 100. Nix never lets with override an explicit binding.
Why this exists: with is dynamic. The evaluator cannot know at parse time which names s contains, so a static binding must win or every with would be a scope-corruption risk.
Practical rule: with pkgs; [ nodejs pnpm biome ] for a short package list is fine. with pkgs; around a large block is a trap: you add let lib = ... later, or a function argument named system, and a reference silently points at the wrong thing with no error.
Try it
nix eval --expr 'let a = 100; s = { a = 1; }; in with s; a'100nix eval --expr '{ x = { a = 1; }; } // { x = { b = 2; }; }'{ x = { b = 2; }; }Exercise
Given base = { server = { port = 3000; host = "localhost"; }; debug = false; }, produce a prod set where host is "example.com", port stays 3000 and debug stays false. Do it with // only, no lib. Then explain why the obvious one-liner is wrong.
Solution
let
base = { server = { port = 3000; host = "localhost"; }; debug = false; };
in
base // { server = base.server // { host = "example.com"; }; }{ debug = false; server = { host = "example.com"; port = 3000; }; }The obvious base // { server.host = "example.com"; } evaluates to { debug = false; server = { host = "example.com"; }; }: port is lost, because server.host = ... builds a fresh server set and // replaces the old one wholesale.
Trap
with pkgs; [ nodejs pnpm ] inside a function whose argument pattern already includes { pkgs, nodejs, ... }. The list silently uses the argument nodejs, not pkgs.nodejs. No error, wrong version in the shell. Use inherit (pkgs) or pkgs.nodejs explicitly when the surrounding scope is not trivially small.