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:

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?

NixOS logo

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.

Number of packages in repositories, repology.org

We also find in the landscape, Guix and Spack, which follow the original ideas of Eelco, but adding their own twist to it.

  • Guix use the Scheme language, which is of the Lisp family. Guix System also uses its own init system, called Shepherd.
  • Spack uses Python, and focuses on unprivileged and highly configurable installations at compute centers.
Guix LogoSpack logo

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=${ "A" + "B" }
''
#=> "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-${builtins.toString x}") [ 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: "${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-${x}" "right-${x}" ]) ["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"

"${true}"
#=> 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).

"${ { outPath = "/usr"; } }/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 and pkgs.lib

Continue in the next episode of this series, where we start building packages, with builtins.derivation:

The Nix lectures, part 2: Derivations