Nix language: values and attrsets

Updated

July 30, 2026

Nix language: values and attrsets

Goal: Read and write Nix values, attrsets, letin, and inherit well enough to model config-shaped data and follow nixpkgs snippets.

Note

This chapter is language only—no flakes, no NixOS rebuilds. Everything runs in nix repl or nix eval.

Why this chapter matters

NixOS configuration is nested attribute sets with types and merge rules layered on top. If attrsets feel alien, every later chapter becomes copy-paste. Master the data model once; modules will make sense.

Ops motivation: reading an error about a missing attribute, writing a host model, and reviewing a teammate’s module all require fluent set/list/string literacy—not memorizing every option.


Theory 1 — Values are few; composition is everything

Nix has a small value inventory:

Kind Examples Notes
Integer 1, -3 Arbitrary precision integers
Float 1.5 Prefer integers unless you need floats
Boolean true, false
String "hello", ''multi'' Two quoting styles
Path ./foo.nix, /etc/hosts Distinct from strings
Null null Often “unset” in APIs
List [ 1 2 3 ] Heterogeneous allowed; spaces separate elements
Attrset { a = 1; b = "x"; } The backbone
Function x: x + 1 the language functions chapter

Type inspection

builtins.typeOf 1          # "int"
builtins.typeOf "x"        # "string"
builtins.typeOf ./foo      # "path"
builtins.typeOf { a = 1; } # "set"
builtins.typeOf [ 1 2 ]    # "list"
builtins.typeOf (x: x)     # "lambda"

Punchline: almost all “config” is attrsets of attrsets, with lists of packages/strings hanging off leaves.


Theory 2 — Strings: interpolation and two quote forms

Double quotes

let name = "yarara"; in "NixOS ${name}"
# → "NixOS yarara"

${…} interpolates an expression that stringifies cleanly (strings, paths, numbers, …).

Indented strings ('' … '')

''
  line one
  line two
''

Common for shell scripts and multi-line text. Leading indentation is stripped relative to the least-indented line.

Escaping notes

Need Approach
Literal ${ in '' strings ''${
Nested quotes Prefer indented strings for scripts
Path into string "${./file}" copies/context rules apply carefully

Paths vs path-looking strings

./config.nix     # path — can be imported, copied into store
"./config.nix"   # string — just characters

Import expects a path (or something that resolves to one). Confusing the two is a classic beginner bug.


Theory 3 — Lists

[ "git" "vim" "curl" ]
[ 1 2 (3 + 4) ]
  • No commas between elements
  • Parenthesize subexpressions when needed: [ (f x) y ]
  • Concatenate with ++: [ 1 2 ] ++ [ 3 ]

Lists are ordered. Attrsets are keyed. Use the right shape:

Need Prefer
Ordered packages on PATH list
Named options / config tree attrset
Set of users by name attrset of attrsets

List builtins you will reuse

builtins.length [ 1 2 3 ]
builtins.head [ 1 2 3 ]
builtins.tail [ 1 2 3 ]
builtins.elemAt [ 10 20 30 ] 1
builtins.concatLists [ [ 1 ] [ 2 3 ] ]
builtins.filter (x: x > 1) [ 0 1 2 ]
builtins.map (x: x * 2) [ 1 2 3 ]

Theory 4 — Attribute sets (the main event)

{
  hostName = "lab";
  enable = true;
  ports = [ 22 80 ];
}

Access

let cfg = { hostName = "lab"; enable = true; };
in cfg.hostName
# or
cfg.${"hostName"}

Nested sets

{
  networking = {
    hostName = "lab";
    firewall = {
      enable = true;
      allowedTCPPorts = [ 22 ];
    };
  };
}

This is the shape of NixOS config, even before the module system:

config
 └─ networking
      ├─ hostName
      └─ firewall
           ├─ enable
           └─ allowedTCPPorts

Dynamic keys

let key = "hostName";
in { ${key} = "lab"; }

Quoted keys for unusual names: { "my-key" = 1; }.

Update / merge intuition

# shallow “override” via //
{ a = 1; b = 2; } // { b = 9; c = 3; }
# → { a = 1; b = 9; c = 3; }

// is shallow. Nested sets are replaced wholesale unless you merge carefully (later: lib.recursiveUpdate, module merge).

{ n = { x = 1; }; } // { n = { y = 2; }; }
# → { n = { y = 2; }; }   # x is gone!

Attrset builtins

builtins.attrNames { z = 1; a = 2; }
builtins.attrValues { a = 10; b = 20; }
builtins.hasAttr "a" { a = 1; }
builtins.getAttr "a" { a = 1; }
builtins.removeAttrs { a = 1; b = 2; } [ "a" ]

Theory 5 — letin (local bindings)

let
  a = 1;
  b = a + 2;
in
  a + b
# → 4

Rules that matter

  1. Bindings in one let can refer to each other (order-independent among pure values).
  2. Scope ends at the in expression.
  3. Prefer let over repeating huge expressions.
  4. Infinite recursion is possible if a binding depends on itself without a fixed point.
let x = x + 1; in x   # boom: infinite recursion

Nested lets vs one flat let

Either works; flat is often clearer for small configs:

let
  host = "lab";
  user = "alice";
  packages = [ "git" "vim" ];
in {
  networking.hostName = host;
  users.users.${user}.isNormalUser = true;
  environment.systemPackages = packages; # shape only — not real pkgs yet
}

rec attrsets

rec {
  a = 1;
  b = a + 1; # refers to a in the same set
}

Useful; easy to create cycles. Prefer let when clarity wins.


Theory 6 — inherit (pull names into sets or lets)

Into an attrset

let
  pkgs = { hello = "h"; git = "g"; };
  system = "x86_64-linux";
in {
  inherit pkgs system;
  # equivalent to: pkgs = pkgs; system = system;
}

Rename form

let hello = "world"; in { inherit ( { x = hello; } ) x; }

Common real form:

{ inherit (pkgs) git vim curl; }
# → { git = pkgs.git; vim = pkgs.vim; curl = pkgs.curl; }

Into let

let
  inherit (builtins) typeOf attrNames;
in
  typeOf { a = 1; }

When to inherit: the name in the outer scope is the name you want. When names differ, write the binding explicitly.


Theory 7 — with (know it; use sparingly)

with builtins; typeOf [ 1 2 ]

with set; expr injects set’s attributes into scope. Powerful and easy to shadow silently.

let
  lib = { id = x: x; };
  id = "oops";
in
  with lib; id   # which id?

House rule for this book: prefer let inherit (lib) … or qualified lib.foo over broad with lib;. You will still read with in older code.


Theory 8 — Conditionals and operators (minimal set)

if true then "a" else "b"

true && false
true || false
!true

1 == 1
1 != 2
# ordering on numbers/strings exists; avoid over-cleverness

assert cond; value fails evaluation if cond is false—useful later for module assertions; try once in this chapter:

let x = 2; in assert x > 0; x

Theory 9 — Config-shaped modeling (bridge to NixOS)

You are not writing a full module yet. You are practicing the data shape:

let
  hostName = "lab-vm";
  admin = "alice";
  sshPort = 22;
in {
  networking = {
    inherit hostName;
    firewall = {
      enable = true;
      allowedTCPPorts = [ sshPort ];
    };
  };
  users.users.${admin} = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
  };
  environment.systemPackages = [
    "git"   # stand-ins; real pkgs are derivations later
    "vim"
  ];
}

Notice:

  • Nested attrsets mirror option paths
  • ${admin} dynamic key for username
  • inherit hostName avoids repetition
  • Lists for multi-valued options

Theory 10 — Destructuring patterns you will see

let
  set = { a = 1; b = 2; };
  inherit (set) a b;
in
  a + b
# nested access sugar people write in modules
config.networking.hostName

Reading real nixpkgs becomes pattern matching on these shapes—not learning a second language.


Worked examples bank

Example A — REPL tour

nix repl
1 + 1
{ a = 1; b = { c = 2; }; }.b.c
let x = "nix"; in "hello ${x}"
[ 1 2 ] ++ [ 3 ]
{ a = 1; } // { b = 2; }
builtins.attrNames { z = 1; a = 2; }

Example B — Deep access and missing attrs

let
  cfg = { networking.firewall.enable = true; };
in
  cfg.networking.firewall.enable

Missing attribute → evaluation error. That is good: fail early.

Example C — inherit package list pattern

let
  tools = {
    git = "git-placeholder";
    vim = "vim-placeholder";
    curl = "curl-placeholder";
  };
in {
  environment.systemPackages = with tools; [ git vim curl ];
  # or better without with:
  # environment.systemPackages = [ tools.git tools.vim tools.curl ];
}

Example D — Shallow merge trap

let
  base = { services.foo = { enable = true; port = 80; }; };
  overlay = { services.foo = { port = 8080; }; };
in
  base // overlay
# services.foo.enable disappeared — shallow //

Journal this; module system merges differently later (host modules chapters).

Example E — Dynamic user keys

let
  mkUser = name: {
    users.users.${name} = {
      isNormalUser = true;
    };
  };
in
  mkUser "alice"

(Functions deepen in the next chapter; the attrset shape is the point here.)


Lab 1 — Workspace and file evaluation

mkdir -p ~/lab/nixos-book/concepts/attrsets
cd ~/lab/nixos-book/concepts/attrsets

Create host-model.nix:

let
  hostName = "lab";
  adminUser = "alice";
  packages = [ "git" "vim" "htop" ];
in {
  networking = {
    inherit hostName;
    firewall.enable = true;
    firewall.allowedTCPPorts = [ 22 ];
  };
  users.users.${adminUser} = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
  };
  environment.systemPackages = packages;
}

Evaluate:

nix eval --file ./host-model.nix
nix eval --file ./host-model.nix --apply 'cfg: cfg.networking.hostName'

Lab 2 — Nested merge experiments

In merge-trap.nix, reproduce Example D. Also try nested manual merge:

let
  base = { services.foo = { enable = true; port = 80; }; };
  overlay = { services.foo = { port = 8080; }; };
  deep =
    base
    // {
      services = base.services // {
        foo = base.services.foo // overlay.services.foo;
      };
    };
in
  deep

Confirm enable survives. Write one sentence on why // alone is not “config merge.”


Lab 3 — Attrset utilities in the REPL

builtins.attrNames { c = 1; a = 2; b = 3; }
builtins.attrValues { a = 10; b = 20; }
builtins.hasAttr "a" { a = 1; }
builtins.getAttr "a" { a = 1; }

Build a tiny “user directory”:

let
  users = {
    alice = { uid = 1000; shell = "bash"; };
    bob = { uid = 1001; shell = "zsh"; };
  };
in {
  names = builtins.attrNames users;
  aliceShell = users.alice.shell;
}

Lab 4 — Model something real from your life

Expand host-model.nix (or a second file) to include fictional but realistic keys for:

  1. Hostname + domain
  2. Two users (one admin)
  3. Three “packages” as strings
  4. Firewall ports for SSH + one app
  5. A services.myapp = { enable = true; … } nest

Use let + inherit at least twice. No need for real NixOS options names to validate—accuracy of shape matters.


Lab 5 — Teach-back

Without looking up, write:

  1. Difference between path and string
  2. Why lists use spaces not commas
  3. What // does to nested sets
  4. When you would use inherit
  5. Why broad with is risky

Exercises

  1. Write attrsets for: web service config, user map, package list—choose list vs set deliberately.
  2. Convert a JSON-like mental model of your laptop hostname/firewall into Nix.
  3. Break evaluation with a missing attribute; fix with a default in let.
  4. Demonstrate rec vs let for the same binding graph.
  5. Use indented strings to draft a tiny shell script body (string only).
  6. Build a deep nest 4 levels deep; access the leaf two ways.
  7. Implement shallow vs manual deep merge; document the difference.
  8. Prefer inherit (builtins) … in a file and evaluate it.
  9. Find three places with would hurt readability; rewrite without it.
  10. Model two hosts in one attrset keyed by hostname.
  11. Use assert to enforce sshPort > 0.
  12. Read a random small snippet from nixpkgs (online) and label each value kind.
  13. Explain why NixOS options look like nested attrsets even though types exist.
  14. Commit your ~/lab/nixos-book/concepts/attrsets experiments.

Common pitfalls

Symptom Theory
error: undefined variable Scope / missing let binding
unexpected ',' in list No commas in Nix lists
Path vs string import confusion Paths are a distinct type
Nested keys “disappeared” after // Shallow merge
with silently shadows Prefer explicit / inherit
Dynamic key without ${} Use { ${name} = …; }
Infinite recursion in let Self-reference without base
Semicolon missing in attrset Every binding ends with ;
Treating lists as sets Wrong shape for named config

Checkpoint

  • Named all major value kinds
  • Wrote nested attrset for host-shaped config
  • Used letin and inherit deliberately
  • Demonstrated shallow // pitfall
  • Evaluated a .nix file with nix eval
  • Personal notes include ≥3 gotchas
  • Can choose list vs attrset for a new field

Further depth (this book)

Topic Chapter
Functions over these values Nix language: functions
Multi-file + lib helpers Laziness, imports, and lib
Module merge (not //) Host: Modules as a consumer
Real host config trees Host: Flake-defined hosts