Let-In Blocks and Local Bindings

Updated

September 12, 2026

Let-In Blocks and Local Bindings

There is no x = 1 that you later change. The boring default is: let names, in uses them, bindings may mention each other in any order, and unused names (even throw) stay cheap.

Mental model

let
  b = a + 10;
  a = 5;
in
b   # 15

Order in the let is not execution order. Nix builds a graph. b needs a; a is 5.

inherit x; in an attrset is x = x; — copy the name from scope.

Nested let shadows. The outer name is still there for the outer in.

Worked examples

Case 1: Desk URL from parts

Save as desk_service.nix:

# desk_service.nix
let
  domain = "internal.desk";
  port = 443;
  protocol = "https";
  serviceUrl = "${protocol}://${domain}:${toString port}";
in
{
  inherit domain port;
  url = serviceUrl;
}
nix eval --file desk_service.nix --json

Output:

{"domain":"internal.desk","port":443,"url":"https://internal.desk:443"}

Case 2: Order does not matter

Save as order.nix:

# order.nix
let
  c = b + 1;
  b = a + 1;
  a = 1;
in
c
nix eval --file order.nix

Output:

3

Write names in dependency order anyway. Humans read top-down even when Nix does not.

Case 3: Shadowing

Save as scope.nix:

# scope.nix
let
  tier = "standard";
in
{
  outerTier = tier;
  overridden =
    let
      tier = "premium";
    in
    tier;
}
nix eval --file scope.nix --json

Output:

{"outerTier":"standard","overridden":"premium"}

Case 4: Unused throw is fine

Save as unused.nix:

# unused.nix
let
  domain = "internal.desk";
  unused = throw "never evaluated";
in
domain
nix eval --file unused.nix

Output:

"internal.desk"

A 200-line let of unused helpers is still a readability problem. Laziness does not make a mess cheap for the next reader.

Case 5: inherit from a set

Save as inherit_from.nix:

# inherit_from.nix
let
  cfg = { host = "desk"; port = 8080; extra = true; };
in
{
  inherit (cfg) host port;
}
nix eval --file inherit_from.nix --json

Output:

{"host":"desk","port":8080}

extra stayed in cfg. inherit (cfg) host port is the boring way to pick keys.

The trap

The trap is a 200-line let of unrelated helpers. Split files (import, next chapters) when a binding cluster has a name (db, tls, desk-api).

The other trap is circular let: a = b; b = a;. Eval loops. rec attrsets have the same foot-gun (next chapter).

The boring rule

  • let for names. in for the value you mean.
  • Bindings can be in any order; write them in human order.
  • inherit / inherit (set) instead of host = host.
  • Unused bindings can throw; still delete them if they confuse.
  • Split large lets into files.

Try this

  1. Add unused = throw "x"; to desk_service.nix; eval still works.
  2. Reverse three bindings as in Case 2; eval.
  3. inherit (cfg) missing; and read the error.
  4. Nested let that shadows port; print both outer and inner.