Skip to content

02 The language Lesson 11 18 min

Builtins and lib

Tell builtins from lib, use the ten functions that cover most real code, and find the rest fast.

Why this matters

Nix has no standard library in the language itself. There are 116 builtins baked into the evaluator, and a much larger lib that ships inside nixpkgs. Half of reading a flake is recognising lib.genAttrs, lib.optional, mapAttrs and knowing which one needs pkgs in scope.

Concept

Two sources of functions

builtins is a global attrset. Always available, even with no nixpkgs:

builtins.length (builtins.attrNames builtins)
116

A few are also exposed as bare globals: map, toString, throw, import, derivation, baseNameOf, dirOf, removeAttrs, abort. Everything else is builtins.x.

lib is Nix code, in nixpkgs/lib/. You get it from pkgs.lib or nixpkgs.lib in a flake. It wraps builtins with nicer names and adds hundreds more. Check the version to see it is just data from nixpkgs:

nix eval nixpkgs#lib.version
"26.11.20260929.b6c8664"

Rule: if you are in a plain nix eval --expr with no nixpkgs, you have builtins only. Inside a flake output, use lib; it reads better and has the functions people expect.

Lists: map, filter, fold

[
  (builtins.map (x: x + 1) [ 1 2 3 ])
  (builtins.filter (x: x > 1) [ 1 2 3 ])
  (builtins.foldl' (acc: x: acc + x) 0 [ 1 2 3 4 ])
]
[ [ 2 3 4 ] [ 2 3 ] 10 ]

foldl' is strict in the accumulator; the apostrophe is part of the name. Also: builtins.elem, builtins.head, builtins.tail, builtins.concatLists, builtins.genList, builtins.sort. lib adds lib.flatten, lib.unique, lib.last, lib.range.

Attrsets: mapAttrs, genAttrs, filterAttrs

mapAttrs transforms values, keeping keys. It is a builtin:

builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }
{ a = 2; b = 4; }

genAttrs builds a set from a list of names. This one is lib only:

nix eval --expr 'let lib = (builtins.getFlake "nixpkgs").lib; in lib.genAttrs [ "nodejs" "pnpm" ] (name: "pkg:${name}")' --impure
{ nodejs = "pkg:nodejs"; pnpm = "pkg:pnpm"; }

This is how forAllSystems works in flakes: lib.genAttrs [ "x86_64-linux" "aarch64-darwin" ] (system: ...).

Also from lib.attrsets: filterAttrs (n: v: v > 1), mapAttrsToList (n: v: "${n}=${toString v}") giving [ "a=1" "b=2" ], recursiveUpdate for the deep merge that // is not, attrByPath [ "a" "b" ] fallback set. Builtins: attrNames, attrValues, hasAttr, getAttr, removeAttrs, intersectAttrs, listToAttrs.

Strings

[
  (builtins.concatStringsSep ", " [ "a" "b" "c" ])
  (builtins.replaceStrings [ "a" ] [ "o" ] "banana")
  (builtins.substring 0 3 "hello")
  (builtins.stringLength "hello")
]
[ "a, b, c" "bonono" "hel" 5 ]

lib.strings adds the ones you reach for daily: toUpper, hasPrefix, splitString "." "1.2.3" giving [ "1" "2" "3" ], removeSuffix, concatMapStringsSep, optionalString cond "text". Note builtins.split is a regex splitter that returns captures interleaved: builtins.split "," "a,b,c" is [ "a" [ ] "b" [ ] "c" ]. Use lib.splitString for the obvious behaviour.

Conditionals as data: optional, optionals

The most common lib idiom in package lists:

nix eval nixpkgs#lib.optional --apply 'f: [ (f true "yes") (f false "yes") ]'
[ [ "yes" ] [ ] ]

packages = [ pkgs.nodejs ] ++ lib.optional withTurso pkgs.turso-cli; adds the item only when the flag is true. lib.optionals takes a list instead of one item.

JSON

builtins.toJSON and builtins.fromJSON convert both ways:

builtins.fromJSON (builtins.toJSON { a = 1; b = [ true null ]; })
{ a = 1; b = [ true null ]; }

nix eval --json uses the same conversion. Handy for feeding Nix data to Node.

Finding functions

Three routes, in order:

  1. noogle.dev. Search by name or by type signature. Type genAttrs, read the signature and example. Covers builtins and lib.
  2. :doc in the repl for builtins: :doc builtins.map. See The Nix REPL.
  3. Read the source. Every lib function has a position:
nix eval nixpkgs#lib.attrsets --apply 'a: builtins.unsafeGetAttrPos "genAttrs" a'
{ column = 3; file = "/nix/store/pzwzs371qflfnkdg37gisaz1pl7qa2jg-source/lib/attrsets.nix"; line = 1351; }

Open that file at that line. lib source has doc comments with a # Type section above every function, for example genAttrs :: [String] -> (String -> a) -> { [String] :: a }, followed by an example. It is the best documentation Nix has.

Try it

nix eval nixpkgs#lib.strings --apply 's: s.splitString "." "1.2.3"'
[ "1" "2" "3" ]
nix eval --expr 'builtins.split "," "a,b,c"'
[ "a" [ ] "b" [ ] "c" ]

Same intent, different output. --apply lets you call a function from a flake attribute without --impure.

Exercise

Using only builtins, turn { nodejs = "22.23.3"; pnpm = "12.3.4"; } into the single string "nodejs@22.23.3 pnpm@12.3.4".

Solution
let
  versions = { nodejs = "22.23.3"; pnpm = "12.3.4"; };
  pairs = builtins.attrValues (builtins.mapAttrs (n: v: "${n}@${v}") versions);
in
builtins.concatStringsSep " " pairs
"nodejs@22.23.3 pnpm@12.3.4"

mapAttrs keeps the keys, so attrValues is needed to get a list. With lib this is one call: lib.concatStringsSep " " (lib.mapAttrsToList (n: v: "${n}@${v}") versions).

Trap

builtins.currentSystem in a flake. It is not there:

error: attribute 'currentSystem' missing

Flakes evaluate in pure mode, and currentSystem is impure. That is why every flake hard-codes system = "x86_64-linux" or uses genAttrs over a list of systems. If a snippet from a blog uses currentSystem, it predates flakes or needs --impure.

Checkpoint

1/3Which is available in nix eval --expr with no nixpkgs?
2/3What does lib.genAttrs [ "a" "b" ] (n: 1) return?
3/3Why does builtins.split differ from lib.splitString?