Nix Language Primer
This primer introduces the Nix expression language. The goal is not to make you a Nix language expert, but to make the syntax and core ideas feel predictable so later topics—flakes, packages, modules—don’t feel mysterious.
Nix is:
- Pure (no hidden side effects)
- Lazy (values are only evaluated when needed)
- Expression-based (everything returns a value)
There are no statements—only expressions.
Why use an expression language?
Section titled “Why use an expression language?”An Example Problem With Procedural Languages
Section titled “An Example Problem With Procedural Languages”type Config = { port?: number; logging?: { level?: string; json?: boolean };};
function applyBase(cfg: Config) { cfg.port = 8080; cfg.logging = { level: "info", json: false };}
function applyProd(cfg: Config) { cfg.logging!.json = true; cfg.logging!.level = "warn";}
const cfg: Config = {};applyBase(cfg);applyProd(cfg);
console.log(cfg);- If you accidentally call applyProd before applyBase, you crash (cfg.logging!).
- If a third function mutates logging completely, you lose fields (“oops, json reset to false”).
- Deep merge semantics become “whatever the last function did”, not a predictable rule.
- The config is only correct after running all steps in the right order.
Types and Primitives
Section titled “Types and Primitives”Nix is dynamically typed, but it has a small, fixed set of core types.
Basic types
Section titled “Basic types”123 # integer3.14 # float"hello" # stringtrue / false # booleansnull # null valueStrings can be:
"hello ${name}" # interpolated string'' # multiline string multi line''Interpolation only works in double-quoted or multiline strings.
Paths are not strings.
./file.txt../other-dir/nix/store/abcd...This matters because paths are copied into the Nix store automatically when evaluated.
builtins.readFile ./config.txtattrsets (attribute sets)
Section titled “attrsets (attribute sets)”Attrsets are key–value maps, similar to objects or dictionaries.
{ name = "alice"; age = 30;}Keys:
- Are identifiers (or quoted strings)
- Order does not matter
Accessing attributes:
person.nameNested attrsets:
{ user = { name = "alice"; shell = "zsh"; };}Dynamic keys
Section titled “Dynamic keys”let key = "answer"; in{ ${key} = 42;}Lists are ordered collections.
[ 1 2 3 ][ "a" "b" "c" ]Mixed types are allowed:
[ 1 "two" true ]Indexing uses builtins.elemAt:
builtins.elemAt [ "a" "b" "c" ] 1# => "b"Common list operations (you’ll see these everywhere):
map (x: x * 2) [1 2 3]filter (x: x > 2) [1 2 3 4]Let bindings
Section titled “Let bindings”let ... in ... introduces local names.
let x = 2; y = 3;inx + y- Everything after
incan see the bindings - Bindings are recursive (they can reference each other)
Functions
Section titled “Functions”Functions are first-class values.
Function basics
Section titled “Function basics”x: x + 1Calling a function:
(x: x + 1) 5# => 6Multiple arguments are written as curried functions:
x: y: x + yCalled as:
(x: y: x + y) 2 3Functions with attrsets (very important)
Section titled “Functions with attrsets (very important)”This is the most common pattern in Nix.
{ name, age }: "Hello ${name}, you are ${toString age}"Calling it:
(fn { name = "alice"; age = 30; })Default values
Section titled “Default values”{ name, age ? 42 }: ageCatch-all arguments (...)
Section titled “Catch-all arguments (...)”{ name, ... }: nameThis allows extra attributes without error—used heavily in modules.
Conditionals
Section titled “Conditionals”if is an expression.
if x > 0 then "positive" else "negative"Both branches must return the same kind of value.
Using the REPL
Section titled “Using the REPL”The Nix REPL is essential for learning and debugging.
nix replInside the REPL:
1 + 2Load a file:
:l ./example.nixPrint values:
:p myValueBring common helpers into scope (load flake):
:lf nixpkgsThe REPL is especially useful for:
- Exploring attrsets
- Checking function outputs
- Understanding error messages
Working with Nix files
Section titled “Working with Nix files”A .nix file evaluates to a single expression.
{ name }: "Hello ${name}"Evaluate it:
nix eval --impure --expr '(import ./example.nix {name = "foo";})'Importing other files:
let config = import ./config.nix;inconfig.valueImports are pure and cached.
Equality and operators
Section titled “Equality and operators”Equality:
1 == 11 != 2Logical operators:
true && falsetrue || false!trueString concatenation:
"hello " + "world"List concatenation:
[1 2] ++ [3 4]Attrset merge:
{ a = 1; } // { b = 2; }Right side wins on conflicts.
Laziness (why this matters)
Section titled “Laziness (why this matters)”Nix is lazy.
let x = throw "error";in1This evaluates to 1 without error.
Why this matters:
- Infinite structures are possible
- Errors only happen when values are used
- Many configs define values that are never evaluated
Builtins and the standard library
Section titled “Builtins and the standard library”builtins is always in scope.
Common ones:
builtins.readFilebuiltins.toStringbuiltins.mapbuiltins.attrNamesbuiltins.traceDebugging:
builtins.trace "hello" valuePatterns you’ll see later (preview)
Section titled “Patterns you’ll see later (preview)”These will make more sense now:
Passing attrsets through layers
Section titled “Passing attrsets through layers”{ pkgs, lib, config, ... }:Overlays and overrides
Section titled “Overlays and overrides”self: super: { myPkg = super.myPkg;}self: super: { myPkg = super.myPkg.override { name = "myPkg-custom"; };}Flake outputs
Section titled “Flake outputs”outputs = { self, nixpkgs }: { packages.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.hello;};You don’t need to fully understand these yet—but the syntax should no longer look alien.
Summary mental model
Section titled “Summary mental model”- Everything is an expression
- Attrsets are the backbone of configuration
- Functions frequently take attrsets
- Laziness is a feature, not a bug
- If the syntax looks weird, it’s usually because it’s data, not control flow