The Nix lectures, part 1: Introduction and language basics
19 September 2024
·
20 min read
This is part 1 of a tutorial series that covers all about Nix. You can find the rest here:
- Part 1: Introduction and language basics
- Part 2: Derivations
- Part 3: NixOS and module system (coming soon)
- Part 4: Integration (coming soon)
In this tutorial series, I want to give a broad view of Nix and NixOS, covering every aspect. I want to cover the topics in the just enough level of detail. This means, some topics will have a more detailed explanation, while some details will be hand-waved — this is not a hacker’s handbook.
The source code of my blog is open source software at viperML/neohome. You are invited to contribute to it, improving the wording of the text, fixing the technical details, or whatever you want.
What is Nix and NixOS?
Nix can be defined as a purely functional package manager, that enables reproducible and declarative builds and deployments. That is quite to unpack:
- Package manager: the role of Nix is similar to the ones of apt and dnf in other Linux distributions. It is the software that organizes the rest of your software, and the dependencies between them. Nix can also handle packages from the language-specific ecosystems, like Python or NPM. It also runs on macOS.
- Purely functional: Nix is also a functional programming language. We define packages in this language, and use the command line to “instantiate” the functional data structures that represent packages, into the disk. People describe the Nix the language as “JSON with functions” or “Haskell without types”.
- Reproducible: in math, function produce the same values given the same inputs. f(x) = x² doesn’t depend on the time of the day. Similarly, we call pure function in programming to functions that only depend on their inputs. Nix applies this approach to software packaging, by having a tight sandbox to build the packages in, and a purely functional language to describe the instructions.
- Declarative: instead of running multiple commands, we define files that collect all the details. This way, nothing is lost on countless lines of our shell history.
Some of the key features of the package manager are:
- It can run on top of any Linux distribution, like Debian or Arch.
- MacOS is also supported, as well as ARM64 targets for Linux and Mac.
- Packages are stored independently of the system in
/nix/store
- Packages are addressed by a cryptographic hash of their inputs and build
procedure. Multiple packages of the same name can coexist in the same system, if
they refer to different binaries:
$ find /nix/store -maxdepth 1 | grep "fish-3.7.1\$" /nix/store/qkjx3hgnpdvyg3m7973dh4hr1jxbqphw-fish-3.7.1 /nix/store/5c27daphi2jn4aj714pf8jvdqn087989-fish-3.7.1 /nix/store/9g2wgp6wgl8z2x3ikqab0x8dlbqj3b63-fish-3.7.1
- There is no global library path for packages built with Nix. Each package
referes to their inputs explicitely:
$ ldd /nix/store/qkjx3hgnpdvyg3m7973dh4hr1jxbqphw-fish-3.7.1/bin/fish linux-vdso.so.1 (0x00007fd3a1f1c000) libncursesw.so.6 => /nix/store/z7nr6aqlzv51pk5ar8bgzg2alfqvi8fd-ncurses-6.4.20221231/lib/libncursesw.so.6 (0x00007fd3a1ea0000) libdl.so.2 => /nix/store/3dyw8dzj9ab4m8hv5dpyx7zii8d0w6fi-glibc-2.39-52/lib/libdl.so.2 (0x00007fd3a1e9b000) libpcre2-32.so.0 => /nix/store/9acpvpxwa33gcf5cnjs9136b38k5m62m-pcre2-10.44/lib/libpcre2-32.so.0 (0x00007fd3a1e0f000) libstdc++.so.6 => /nix/store/22nxhmsfcv2q2rpkmfvzwg2w5z1l231z-gcc-13.3.0-lib/lib/libstdc++.so.6 (0x00007fd3a1a00000) libm.so.6 => /nix/store/3dyw8dzj9ab4m8hv5dpyx7zii8d0w6fi-glibc-2.39-52/lib/libm.so.6 (0x00007fd3a1d27000) libgcc_s.so.1 => /nix/store/22nxhmsfcv2q2rpkmfvzwg2w5z1l231z-gcc-13.3.0-lib/lib/libgcc_s.so.1 (0x00007fd3a1d02000) libc.so.6 => /nix/store/3dyw8dzj9ab4m8hv5dpyx7zii8d0w6fi-glibc-2.39-52/lib/libc.so.6 (0x00007fd3a1809000)
A bit of history
Nix starts as a research project in the Netherlands in 2003. Eelco Dolstra publishes his PhD thesis The Purely Functional Software Deployment Model, that covers the deficiencies of the existing packaging solutions and presents the very first version of Nix. Soon after, a complete Linux distribution based on Nix was born: NixOS.
Since then, NixOS has had bi-yearly releases, and the repository holding the package definition, nixpkgs, has grown to be the biggest repository ever.
We also find in the landscape, Guix and Spack, which follow the original ideas of Eelco, but adding their own twist to it.
Key concepts
- Nix refers to Nix the program and Nix the language.
- We use Nix (the program) to evaluate code written in Nix (the language).
- A derivation is a type of value, that can produce an output in the
/nix/store
. - Packages can be 1:1 derivations, but derivations are very cheap to create.
- Knowing the Nix language is fundamental to understanding how to use the Nix command line, because they operate in Nix expressions.
- Nixpkgs is the repository that contains the collection of all Nix packages. It is distributed independently of Nix, the program.
More resources
I invite you to look a the different resources to learn Nix, either the reference manuals or other guides and tutorials:
- nix.dev: official guides and tutorials
- Nix manual: documentation of the language and the CLI.
- Nixpkgs manual: documentation of the abstractions used in nixpkgs.
- NixOS manual: documentation and usage of the operation system, and of the module system.
- NixOS Discourse: community forum where you can ask anything related to Nix.
Nix repl and nix eval
We begin our journey by taking a tour over the Nix language. To get a nice grasp about Nix as a whole, it is important to know about the language, instead of mindlessly running commands in our terminal — we will do that in the last episode.
We can either write Nix code in a file ending with an extension .nix
, or use
nix repl
to evaluate expressions in a “command line”-like experience:
nix repl
: to type your expressions in the command-line.nix eval
: to type your expressions in a file.
You are free to use whichever. I would recommend putting the expressions in a nix file and using your code editor. It will be easier to edit your code when it spans multiple lines, and you will be able to back-up your progress.
So, open in your favorite text editor any filename ending in .nix
. Note that
Nix doesn’t have any project structure, or any special filenames.
# tuto.nix
"Hello world!"
$ nix eval -f ./tuto.nix
"Hello World!"
I may use comments to show the output of an expression, to simplify the code blocks:
"Hello world!"
#=> "Hello world!"
Types
Nix is a dynamically typed language, which means that you don’t write the type of a value. Instead, it is calculated at runtime. Nix is also a very simple language, and it has very few types.
NOTE
One of the objectives of Nix is software reproducibility, so it needs a very simple language to use a base. You wouldn’t want to add new features every year, making it impossible to use old Nix code.
I like to have a mental model of types in Nix, where we find the categories:
- Primitive types
- Compound types: attribute sets and lists
- Functions
Primitive types
Strings
This is the most important type in Nix. Strings behave mostly like in any other programming language. You have 2 variants of a string:
"foo"
, with single double quotes''foo''
, with double single quotes
The difference is that ''foo''
trims the leading whitespace. This is useful
for multiline strings, where you will see them used.
''
export FOO=BAR
''
You can also interpolate values with ${}
inside a string:
''
export FOO=${ }
''
#=> "export FOO=AB\n"
Strings can be concatenated with +
.
Booleans
We have true
and false
. You will use them for if ...
.
Null
Apart from the booleans, we also have null
. You may see it in NixOS modules,
to signify the lack of some configuration.
null
and false
are different. You can’t use null
in if ...
.
Numbers
Mostly used for demonstration purposes, you probably won’t
see any number in the wild. Nix has int
and float
types, which coerce
automagically.
1 + 2 * 2 / 1.1
#=> 4.63636
Paths
Nix also has a built-in type for paths. There are occasions that you will want a path instead of a string containing a path, which are mostly relevant to flakes.
But, for an introduction, you can use either strings or paths indifferently.
/usr/bin/env
#=> /usr/bin/env
Compound types
These types are made of any of the other values. While lists collect unnamed values, attribute sets collects named values.
Lists
Lists are space separated, and are heterogeneous. This means you can mix any other type of value inside:
[
1
"hello"
[
"nested list"
]
]
As a style guide, we put new items in new lines. This helps the human reader parse the different elements.
You can concatenate lists with the double plus sign ++
:
[ 1 ] ++ [ 2 ]
#=> [ 1 2 ]
Attribute sets
Or attrsets for short, are a collection of key-value pairs. The keys must be valid identifiers (not expressions), and the values can be of any types. The semicolons are obligatory.
{
foo = "bar";
x = [ 0 ];
}
You can nest attribute sets freely. However, there is some special syntax shorthand for it. Note that this is only possible when you are writing nested attrsets.
{
a = {
b = "c";
};
# or shortened:
a.b = "c";
}
To select an item from an attrset, you use a dot .
:
{
x = 1;
y = 2;
}.x
#=> 1
The keyword inherit x
is a shorthand for x = x
.
let
foo = "value";
in
{
# these two are equivalent
foo = foo;
inherit foo;
}
TIP
inherit
is not related to inheritance in OOP languages. It is just a very
simple syntax shorthand.
Functions
It won’t come as a surprise, that functions are the most important type in Nix, which is a functional language. Functions are defined with the following syntax:
argument: body
# sometimes you need parenthesis
(argument: body)
To apply a function, you use a single space:
(x: x + 1) 2
#=> 3
IMPORTANT
Functions take a single argument, and return a single value from its body.
While this might seem like a limitation, you can write functions that take multiple values in two ways. One way, is to take an attrset as an argument, which then you can decompose:
x: x.foo + x.bar
To help with this common pattern, there is some special syntax for decomposing the input. Don’t confuse this syntax with how you declare an attrset, this is just for functions:
x: x.foo + x.bar
# equivalent to
{ foo, bar }: foo + bar
To accept multiple argument, it is also possible to use functions that return functions. In the following example, the outer function return another function. So you can apply it twice to get the final value:
foo: (bar: foo + bar)
You can call this function with 1
, and then call the returned function again
with 2
.
# ~pseudo-code~
f = foo: (bar: foo + bar)
# substituting foo=1
(bar: 1 + bar)
# substituting bar=2
1 + 2
#=> 3
# Which is the same as:
f 1 2
#=> 3
One way to know if you are dealing with functions or partially-applied
functions, is that Nix displays them as lambda
:
(x: y: x + y)
#=> «lambda»
#=> you can't print a function
(x: y: x + y) 1
#=> «lambda»
#=> still the inner lambda
(x: y: x + y) 1 2
#=> 3
WARNING
Both functions and lists are aware of spaces: functions use space for application, and lists are space-separated. Lists take precedence over functions. Be careful when applying functions inside lists:
[
f 1 # two elements, f and 1
(f 1) # the result of applying f 1
]
Compound expressions
One of the key insights to understanding Nix’s syntax, is that everything is an expression. Expressions can be nested into another. Unlike a regular language, there are no “statements” in Nix. Statements run one after the other.
An statement would imply multiple values. In contrast, in Nix we have a single expression, that evaluates from the bottom.
One way to visualize this, is by adding parenthesis for every expression:
# Given the following expression:
[ 1 ] ++ [ { foo = x: y + 1; } ]
# From the bottom:
(expr) ++ (expr)
[ (expr) ] ++ [ (expr) ]
[ 1 ] ++ [ { foo = (expr); } ]
In the following sections, we will look at some of the special syntax that nix has, that prefixes expressions.
if-then-else
The syntax for if is the following:
if expr-cond then expr-true else expr-false
Given expr-cond
, if it evaluates to true
, then the expr-true
is selected.
If it evaluates to false
, then expr-false
is used. Otherwise, if you pass
any other type, a runtime error is raised.
IMPORTANT
if-then-else
is an expression, you can handle like any regular value.
This means, you can use it as any other expression, for example as attrset keys, as return values of functions or anything more or less complex:
{
config = if useCuda then "cuda" else "noCuda";
# using parenthesis to visualize the expressions
config' = (if (useCuda) then ("cuda") else ("noCuda");
}
You can “fake” an if-else, by nesting another if-expression in the expr-false
branch:
(if false
then "a"
else (if true
then "b"
else "c"))
let-in
let-in
is a prefix to any expression, that allows us to factor out code.
The syntax is the following:
let
x = (expr);
y = (expr);
in
(expr)
The syntax is similar to how we declare attrsets { x = (expr); }
, but without
the curly braces.
When we need to repeat the usage of some variable, we will be using a let-in
most of the time. You might have seen the following example to create a shell:
let
pkgs = import <nixpkgs> {};
# pkgs => { mkShell = ...; hello = ...; .... }
in
pkgs.mkShell {
packages = [
pkgs.hello
];
}
with
with
is a weird one. It allows you to put all the keys of an attrset,
in the scope of the target expression. The syntax is the following.
with (expr-with); (expr)
Note that the first expr must evaluate to an attribute set. We can use with
to
factor out code in the following way:
with {
x = 1;
y = 2;
}; (x+y)
This is oddly similar to let-in
. The key difference is that the argument to
with
can be a variable that we receive as input, or the result of some
computation. For example:
with (import <nixpkgs> {});
mkShell {
packages = [
hello
];
}
Builtins
In this section, we will cover some functions from builtins
. This is the name
of a variable that is provided by Nix itself. I will cover some of the most used
ones. You can check the documentation for the rest in the
Nix manual.
import
import
allows you to load a nix expression from another file. Remember that
nix is an expression-based language, so a file always contains a single expression
that evaluates to a single value.
# foo.nix
let y = 2; in {
x = 1;
z = y + 2;
}
import ./foo.nix
#=> { x = 1; z = 4; }
IMPORTANT
When you pass a folder to import
, it will try to read the
folder/default.nix
file inside it. default.nix
is the only “special”
filename that we have in Nix.
A common use case is to import a file, and then immediately after evaluate the function that it evaluates to:
(import ./foo.nix) "myinput"
This is how the nixpkgs repo has its default.nix
set-up. It takes an attrset,
that is used to configure nixpkgs:
# <nixpkgs> is the "global" nixpkgs installation
# it returns a path
<nixpkgs>
#=> /nix/store/qpg5mwsind2hy35b9vpk6mx4jimnypw0-source
import <nixpkgs>
# «lambda»
(import <nixpkgs>) {}
#=> {
# hello = <drv>;
# python3 = <drv>;
# ....
# }
# you can omit the parenthesis
import <nixpkgs> {}
TIP
I recommend loading nixpkgs in a nix repl
, and exploring the resulting
attrset. You can either use pkgs = import <nixpkgs> {}
, or the repl command
:l <nixpkgs>
, which evaluates it with the empty attrset.
map
map f list
applies the function f
to each element of a list
. When you think of a for
loop in other languages, you can mostly translate it to map
.
builtins.map (x: "workspace-${}") [ 1 2 3 ]
#=> [
# "workspace-1"
# "workspace-2"
# "workspace-3"
# ]
filter
filter f list
, as map, applies f to each element of the list. But it removes
elements for which the function returns false
, and keeps the element if f
returns true
.
builtins.filter (x: builtins.stringLength x > 1) ["f" "bar"]
#=> [ "bar" ]
mapAttrs, filterAttrs
These are the equivalent to map
and filter
, but can be applied to
attrsets instead of lists. They take a curried functions, for the name and
value.
builtins.mapAttrs (name: value: "${}:${}") { foo = "bar"; }
#=> { foo = "foo:bar"; }
readFile, fromTOML, fromJSON
Unlike import
, which reads a file and loads its nix expression, readFile
reads a file and loads them as a string. It can be useful to factor out a big
string into a separate file.
fromTOML
and fromJSON
try to convert a Nix string into a valid Nix value,
usually an attrset or list of other “simple” values. You may see it in
combination with readFile
.
builtins.fromJSON (builtins.readFile ./package.json)
#=> {
# name = "neohome";
# ...
# }
foldl’
Finally, I want to mention foldl'
. It allows you to take a list, and generate
a single value out of it. How the value is generated, depends on what folding
function you pass to it. You also need to provide the nul element, from which
the list is folded from.
For example, you can implement the “sum of all numbers in a list”, by folding the list, with the sum function and 0 as the nul element.
builtins.foldl' (left: right: left + right) 0 [ 1 2 3 4 ]
#=> 10
Nixpkgs’ standard library
Many other utility functions are implemented in nixpkgs. These are implemented
on top of builtins
, so they are not required to be part of Nix itself. Some of
them can be rewritten easily — for example the identity function lib.id = x: x
, but
in general you will be using them quite often.
You can get lib
from a pkgs
instance, and it is also part of the argument of
NixOS modules. We are writing an standalone nix file, so to get lib
into scope
we can use the following:
let
pkgs = import <nixpkgs> {};
inherit (pkgs) lib;
in
lib.id 3
#=> 3
We will cover some nice functions from lib
, but as with builtins
, I don’t
want you to memorize them. Instead, just keep in the back of
your head that they exist.
traceVal
Based on builtins.trace
, it allows you to print a value into the console. All
you need to do is wrap a value with traceVal
, and make sure you evaluate it.
This will be useful for “print-debugging” functions, so don’t underestimate its value.
builtins.map (x: (lib.traceVal x) + 1) [ 2 3 4 ]
#=> [
# trace: 2
# 3
# trace: 3
# 4
# trace: 4
# 5
# ]
TIP
Remember that Nix is an expression-based language. traceVal
doesn’t behave
like a print statement, but rather it is a function that takes a value and
returns it, printing the value as an effect.
flatten
flatten list
takes a list with lists inside, and transforms it into a “flat”
list.
lib.flatten [ [ 1 2 ] [ 3 4 ] ]
#=> [
# 1
# 2
# 3
# 4
# ]
TIP
flatten
is very useful when used with map
, as it allows map
to “return
multiple values” in the mapping function:
lib.flatten (builtins.map (x: [ "left-${}" "right-${}" ]) ["foo" "bar"])
#=> [
# "left-foo"
# "right-foo"
# "left-bar"
# "right-bar"
# ]
listToAttrs, attrsToList
More often than not, you need to pass a list to an API that takes an attrset, or viceversa. The conversion is not trivial, but you can use these funtions to do so.
To convert an attrset to a list, there are multiple ways to do it:
lib.attrsToList { foo = "foovalue"; bar = "barvalue"; }
#=> [
# {
# name = "bar";
# value = "barvalue";
# }
# {
# name = "foo";
# value = "foovalue";
# }
# ]
builtins.attrValues { foo = "foovalue"; bar = "barvalue"; }
#=> [
# "barvalue"
# "foovalue"
# ]
builtins.attrNames { foo = "foovalue"; bar = "barvalue"; }
#=> [
# "bar"
# "foo"
# ]
For listToAttrs
, you may need to do some conversion with map
before feeding
the result. This is because, listToAttrs
expects a list with attrs inside:
lib.listToAttrs (builtins.map (pkg: { name = pkg.name; value = pkg; }) [ pkgs.hello pkgs.coreutils ])
#=> {
# "coreutils-9.5" = «derivation /nix/store/57hlz5fnvfgljivf7p18fmcl1yp6d29z-coreutils-9.5.drv»;
# "hello-2.12.1" = «derivation /nix/store/crmj28zg09517n5sskml9fmy2c6r3rsr-hello-2.12.1.drv»;
# }
concatStrings
A common abstractionm pattern, is to factor out a big string, into a list of
strings. As you know, you can then reduce a list to a single value with
builtins.foldl'
, but the standard library provides a family of functions that
concatenate strings: concatStrings
, concatStringsSep
, etc.
lib.concatStringsSep "\n" [ "export A=B" "export B=C" ]
#=> "export A=B\nexport B=C"
makeSearchPath, makeBinPath, makeLibraryPath
When dealing with packages, you will often deal with search paths. These are
environment variables used in Linux, that follow the same pattern
elem:elem:elem
. For example, the PATH
environment variable. You can create
search paths with concatStringsSep
and map
, but the standard library
provides some shorthands for this common task:
lib.makeBinPath [ "" "/usr" "/usr/local" ]
#=> "/bin:/usr/bin:/usr/local/bin"
lib.makeLibraryPath [ pkgs.hello pkgs.coreutils ]
#=> "/nix/store/yb84nwgvixzi9sx9nxssq581pc0cc8p3-hello-2.12.1/lib:/nix/store/0kg70swgpg45ipcz3pr2siidq9fn6d77-coreutils-9.5/lib"
Advanced topics
Finally, I want to mention some advanced topics that are part of the base language. If you are just getting started with Nix, you might not need to know about this. Or you might want to read ahead because of curiosity.
Merging attribute sets
I decided to skip the merging operator //
when talking about attrsets. It
takes two attrsets, and inserts the keys from the right-hand side into the
left-hand side. Notice that this definition is very specific to what it does: it
does not try to merge any child attrsets. Looking at an example:
{ a = "avalue"; b = { ba = "bavalue"; bc = "bcvalue"; }; } // { b = 2; c = "cvalue"; }
#=> {
# a = "avalue";
# b = 2;
# c = "cvalue";
# }
Notice that c
was inserted, but also b
, removing the nested attrset from the
left. This can have negative consequences, as naively using //
can lead to
problems.
Merging attrsets is not something that you will do commonly. A prefered approach
would be to create a new attrset, and explicitly listing the key-value pairs
with inherit
.
let c = "avalue"; b = { ba = "bavalue"; }; in { b = { inherit c; inherit (b) ba; }; }
#=> {
# b = {
# ba = "bavalue";
# c = "avalue";
# };
# }
Recursion
You can achieve complex behavior by using the different mechanism for recursion
in Nix. One way is using let-in
, which has a scope in which you can refer to
the item itself:
let
x = [ x ];
in
x
#=> [[[[ ... ]]]]
Because x
itself is in scope, you can create the list that contains itself
(forever). Of course, this is a useless example, but there are other patterns
that are best (or only) implemented with recursion.
The nixpkgs lib also offers lib.fix
, which has a simple definition:
fix =
f:
let
x = f x;
in
x;
Fix takes a function, and applies the function using the result of the function as its argument. Trippy, eh? This allows us to write very compact self-referencing values:
lib.fix (self: { a = 1; b = self.a + 1; })
#=> {
# a = 1;
# b = 2;
# }
Nix also provides the rec
keyword that can be prefixed to attsets, and
provides a similar experience to fix
, but with an implicit scope similar to
let-in:
rec { a = 1; b = a + 1; }
#=> {
# a = 1;
# b = 2;
# }
I prefer to use fix
, because it is more explicit about where things come —
you have to mention your function argument self
, but you can choose whichever
you want.
builtins.toString and string interpolation
There are two ways to convert some value to an string: builtins.toString
and
using string interpolation. They follow different semantics. You could say that
string interpolation is a subset of what you can do with toString
.
builtins.toString true
#=> "1"
"${}"
#=> error: cannot coerce a Boolean to a string: true
IMPORTANT
However, the most important type that you can string interpolate are attrsets.
Attrsets will use its attribute outPath
(a string) or __toString
(a function).
"${ }/bin"
#=> "/usr/bin"
Finale
I hope you got a broad idea about how Nix itself works. In this lesson we learnt:
- What Nix is, and that we need to learn the Nix language to give us superpowers.
- The basic types of the Nix language, such as strings and booleans
- The compound types: lists and attrsets
- Functions and how to apply them
- The most useful functions from
builtins
andpkgs.lib
Continue in the next episode of this series, where we start building packages,
with builtins.derivation
:
The Nix lectures, part 2: Derivations