Skip to content

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.

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.

Nix is dynamically typed, but it has a small, fixed set of core types.

123 # integer
3.14 # float
"hello" # string
true / false # booleans
null # null value

Strings 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.txt

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.name

Nested attrsets:

{
user = {
name = "alice";
shell = "zsh";
};
}
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 ... in ... introduces local names.

let
x = 2;
y = 3;
in
x + y
  • Everything after in can see the bindings
  • Bindings are recursive (they can reference each other)

Functions are first-class values.

x: x + 1

Calling a function:

(x: x + 1) 5
# => 6

Multiple arguments are written as curried functions:

x: y: x + y

Called as:

(x: y: x + y) 2 3

This is the most common pattern in Nix.

{ name, age }: "Hello ${name}, you are ${toString age}"

Calling it:

(fn { name = "alice"; age = 30; })
{ name, age ? 42 }: age
{ name, ... }: name

This allows extra attributes without error—used heavily in modules.


if is an expression.

if x > 0 then "positive" else "negative"

Both branches must return the same kind of value.


The Nix REPL is essential for learning and debugging.

Terminal window
nix repl

Inside the REPL:

1 + 2

Load a file:

:l ./example.nix

Print values:

:p myValue

Bring common helpers into scope (load flake):

:lf nixpkgs

The REPL is especially useful for:

  • Exploring attrsets
  • Checking function outputs
  • Understanding error messages

A .nix file evaluates to a single expression.

example.nix
{ name }: "Hello ${name}"

Evaluate it:

Terminal window
nix eval --impure --expr '(import ./example.nix {name = "foo";})'

Importing other files:

let
config = import ./config.nix;
in
config.value

Imports are pure and cached.


Equality:

1 == 1
1 != 2

Logical operators:

true && false
true || false
!true

String concatenation:

"hello " + "world"

List concatenation:

[1 2] ++ [3 4]

Attrset merge:

{ a = 1; } // { b = 2; }

Right side wins on conflicts.


Nix is lazy.

let
x = throw "error";
in
1

This 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 is always in scope.

Common ones:

builtins.readFile
builtins.toString
builtins.map
builtins.attrNames
builtins.trace

Debugging:

builtins.trace "hello" value

These will make more sense now:

{ pkgs, lib, config, ... }:
self: super: {
myPkg = super.myPkg;
}
self: super: {
myPkg = super.myPkg.override {
name = "myPkg-custom";
};
}
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.


  • 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