Attribute Sets and Recursive Definitions

Updated

September 12, 2026

Attribute Sets and Recursive Definitions

Attrsets are how Nix holds config. The boring default is: plain { }, merge with //, read with . and or, and use rec only when siblings need each other and you will not // over them later.

Mental model

{ host = "127.0.0.1"; port = 8080; }
Op Role
s.port Access; missing → error
s.timeout or 30 Default if missing
a // b Shallow merge; right wins
rec { x = 1; y = x; } Siblings see each other

desk.db.port = 5432; in a let bind is nested sugar in some positions (nested in rec / let via x.y =). Prefer nested { desk.db = { port = 5432; }; } when learning.

Worked examples

Case 1: Merge and or

Save as attr_operations.nix:

# attr_operations.nix
let
  defaultConfig = {
    host = "127.0.0.1";
    port = 8080;
    workers = 4;
  };
  productionOverrides = {
    port = 443;
    workers = 16;
  };
  finalConfig = defaultConfig // productionOverrides;
in
{
  inherit finalConfig;
  customTimeout = finalConfig.timeout or 30;
}
nix eval --file attr_operations.nix --json

Output:

{"customTimeout":30,"finalConfig":{"host":"127.0.0.1","port":443,"workers":16}}

Case 2: rec for sibling URLs

Save as rec_demo.nix:

# rec_demo.nix
rec {
  domain = "desk.corp";
  authService = "https://auth.${domain}";
  billingService = "https://billing.${domain}";
  endpoints = [ authService billingService ];
}
nix eval --file rec_demo.nix --apply 'x: x.authService'

Output:

"https://auth.desk.corp"

A let around the same names is often clearer than rec. Use rec when the set is the value you export.

Case 3: // does not update rec dependents

Save as rec_override.nix:

# rec_override.nix
let
  base = rec { x = 1; y = x + 1; };
  updated = base // { x = 10; };
in
{
  inherit (updated) x y;
}
nix eval --file rec_override.nix --json

Output:

{"x":10,"y":2}

y is still 2. It closed over the original x. If overrides must recompute, use a function:

make = { x }: rec { inherit x; y = x + 1; };
make { x = 10; }  # y = 11

Case 4: Missing attr without or

nix eval --file attr_operations.nix --apply 'x: x.finalConfig.timeout'

Output (shape):

error: attribute 'timeout' missing

Production config: or at the edges, required keys in the module types (part 6).

Case 5: // is shallow (again)

# nest.nix
let
  a = { tls = { enable = true; port = 443; }; };
  b = { tls = { enable = false; }; };
in
a // b
nix eval --file nest.nix --json

Output:

{"tls":{"enable":false}}

port vanished. Rebuild tls = a.tls // b.tls or lib.recursiveUpdate.

# nest-fix.nix
let
  lib = import <nixpkgs/lib>;
  a = { tls = { enable = true; port = 443; }; };
  b = { tls = { enable = false; }; };
in
lib.recursiveUpdate a b
nix eval --impure --file nest-fix.nix --json
{"tls":{"enable":false,"port":443}}

recursiveUpdate is still not the module system (no mkForce). Use it for data. Use modules for NixOS config.

The trap

The trap is rec plus // and expecting dependents to move. Case 3. Prefer let + function.

The other trap is s.foo.bar when foo might be missing — error on foo, not on bar. s.foo.bar or 1 does not save you; or binds the whole s.foo.bar only if the syntax is (s.foo.bar) or 1 vs s.foo.bar or 1 — in Nix, or is tight on the selection. Write (s.foo or {}).bar or 1 when nested.

The boring rule

  • . and or for reads. // for shallow overlays of config.
  • rec for small self-contained sets you will not patch.
  • Overrides that must recompute → function, not rec //.
  • Nested data: merge one level at a time or recursiveUpdate.
  • Missing attributes should error in internal code; or at user-facing edges.

Try this

  1. Access finalConfig.invalid without or.
  2. Rewrite rec_demo.nix as let domain = …; in { … } without rec.
  3. Case 3: replace // with make { x = 10; } and confirm y == 11.
  4. (s.tls or {}).port or 443 on a set without tls.