Values and Immutability

Updated

September 12, 2026

Values and Immutability

Once a Nix value exists, nothing mutates it. The boring default is: // and ++ allocate new values; names in a let are bound once; the original attrset is still what you passed in.

Mental model

Python dicts surprise you:

config = {"port": 8080}
mutate(config)  # original changed

Nix:

  1. Attrsets, lists, and strings do not have setters.
  2. a // b is a new set. a is unchanged.
  3. Memoisation is safe because nothing behind a pointer can change.
{ a = 1; }  ──┐
              ├──► { a = 1; b = 2; }   (new)
{ b = 2; }  ──┘
first set still { a = 1; }

Worked examples

Case 1: Merge does not rewrite

Save as immutable_merge.nix:

# immutable_merge.nix
let
  baseDesk = {
    location = "building-a";
    tier = "standard";
  };
  upgradedDesk = baseDesk // {
    tier = "executive";
    desksCount = 12;
  };
in
{
  original = baseDesk;
  upgraded = upgradedDesk;
}
nix eval --file immutable_merge.nix --json

Output:

{"original":{"location":"building-a","tier":"standard"},"upgraded":{"desksCount":12,"location":"building-a","tier":"executive"}}

baseDesk.tier is still "standard". Fifty functions can receive baseDesk.

Case 2: Lists concatenate to a new list

Save as immutable_list.nix:

# immutable_list.nix
let
  activeTables = [ 1 2 3 ];
  extendedTables = activeTables ++ [ 4 5 ];
in
{
  initial = activeTables;
  result = extendedTables;
}
nix eval --file immutable_list.nix --json

Output:

{"initial":[1,2,3],"result":[1,2,3,4,5]}

There is no .push.

Case 3: Same name twice is an error

Save as double_bind.nix:

# double_bind.nix
let
  x = 1;
  x = 2;
in
x
nix eval --file double_bind.nix

Output (shape):

error: attribute 'x' already defined at double_bind.nix:3

Use a second name (x0, xNext) or an inner let that shadows.

Case 4: Inner let shadows, outer unchanged

Save as shadow.nix:

# shadow.nix
let
  x = 1;
in
{
  outer = x;
  inner = let x = 2; in x;
}
nix eval --file shadow.nix --json

Output:

{"inner":2,"outer":1}

Case 5: Deep merge is not //

# shallow.nix
let
  a = { desk = { port = 80; host = "a"; }; };
  b = { desk = { port = 443; }; };
in
a // b
nix eval --file shallow.nix --json

Output:

{"desk":{"port":443}}

host is gone. // replaces the desk attr wholesale. Nested merge is lib.recursiveUpdate (imports chapter) or a function that rebuilds the nest. Do not expect // to patch deep keys.

The trap

The trap is let x = 1; x = 2;. The trap after that is assuming // is deep. Both look like assignment in other languages. They are not.

The boring rule

  • Values do not change. New names, new values.
  • // shallow. ++ new list.
  • One binding per name per let.
  • Shadow in a nested let, do not reassign.
  • For nested config, write a function or lib.recursiveUpdate, not a pile of //.

Try this

  1. nix eval --expr 'let a = { x = 1; }; b = a // { x = 2; }; in a.x' — expect 1.
  2. Case 5: add lib later; for now rebuild { desk = a.desk // b.desk; } by hand and keep host.
  3. Duplicate a let name; save the error text.
  4. nix eval --expr '[ 1 ] ++ [ 1 ]' — duplicates are allowed; lists are not sets.