Learning Nix - Nix language basics

10/04/2023

A quick summary of the first chapters of the Nix Pills tutorial

If you are new to Nix, Nix Pills is a great tutorial to begin with, and to learn the main concepts of Nix/Nixpkgs from the ground up. In this post I will summarize the concepts I found important in the first chapters, plus some information I found relevant.

Why Nix ?

Being tired of waiting for brew commands to execute, and seeing how painful it can be to properly maintain a system in prod or even to do debian packaging, I recently started to take a look at Nix. It looked promising for multiple reasons, namely:

All this does not come for free however, and will require some work: a new functional programming language to learn, many new concepts and vocabulary (derivations, channels, overlays), some trick commands 2, and a whole new experimental API quickly getting adopted.

In these series of blog posts I will try to document my learning journey, as a newcomer in 2023.

Of course there are already plenty of resources online to learn Nix, so why another series of blog posts? Well, maybe it will only be useful to me. But I found this series – How to learn Nix by Ian Henry – to be extremely useful, so why not following the example?

Installation

Although the official installation script should work fine, the (unofficial) nix-installer seems an interesting alternative (Nix Flakes enabled by default and uninstalling is made easier).

Overview

The Nix language is a functional language. You write Nix expressions in .nix files, which you can compose and combine, in order to produce derivations.

nix-overview

Figure 1: Nix core concepts

Nix language syntax

The first step is to be able to read and write nix expressions, using the Nix language. You can run nix repl to enter an interactive environment and play with the language.

Data structures

Nix supports basic types and operations that can be found in other languages:

nix-repl> builtins.typeOf "abc"
"string"

nix-repl> "a" + ''bc''  # you can enclose strings with double quotes (") or 2 single quotes ('')
"abc"

nix-repl> builtins.typeOf 2
"int"

nix-repl> builtins.typeOf 2.3
"float"

nix-repl> 1 + (2 * 4) - 0.5
8.5

nix-repl> builtins.typeOf ["a" 1.3 [4 5 6]]
"list"

Attribute sets are a key data structure in Nix. Similar to a dictionary in python for instance, they map string keys to values:

nix-repl> { "a" = 1; b = 2; "1/2" = [4 5]; }
{ "1/2" = [ ... ]; a = 1; b = 2; }

nix-repl> rec { a = 1; b = a+c; c = 3; }  # the 'rec' keyword allows defining sets recursively
{ a = 1; b = 4; c = 3; }

Functions

Nix functions can take one or multiple positional parameters, and make it easy to define partial applications.

nix-repl> (x: 2*x) 1.5  # call lambda function 'x: 2*x' on float '1.5'
3

nix-repl> (a: b: 2 * a + b) 1 2  # call the function with a=1, b=2
4

nix-repl> (a: b: 2 * a + b) 1  # partial application, only missing parameter b
«lambda @ (string):1:5»

Using attribute sets, it is also possible to have named parameters:

nix-repl> mul = { a, b }: a * b  # expect an attribute set with at least 'a' and 'b'

nix-repl> mul { a = 1; b = 3; }
3

nix-repl> mul = s: s.a * s.b  # allow other attributes in the input set

nix-repl> mul { a = 1; b = 3; c = "toto" }
3

# allow other attributes in addition to expected parameters (similar to **kwargs in python)nix-repl> mul = s @ { a, b, ... }: a * b + s.c  # also equivalent to: `{ a, b, ... } @ s`

nix-repl> mul { a = 1; b = 3; c = 0.5; d = 0.2; }
3.5

nix-repl> mul = { a, b ? 1 }: a * b  # define default values

nix-repl> mul { a = 1; }
1

A number of builtin functions already exist inside the builtins attribute set, and are accessible directly in nix repl, or inside .nix files.

nix-repl> builtins.sort builtins.lessThan [ 3 1 2 ]
[ 1 2 3 ]
nix-repl> :doc builtins.sort
Synopsis: builtins.sort comparator list
    Return list in sorted order.
    ...

Additional functions are available as part of nixpkgs, inside the lib attribute set.

Special keywords

Just above, in nix-repl, we assigned a value to the variable mul, and simply used it right after. But actual Nix expressions in .nix files do not allow these imperative statements ! In Nix, everything is immutable, so there is no need for assignments statements.

Instead, you can use the let ... in syntax to define variables inside a precise scope (a.k.a. the inner expression):

# in default.nix
let
    a = 1;
    b = a + 1;
in
    { c = b + 0.5; }  # this is the inner expression, in this case also the return value
$ nix-instantiate --eval -E '(import ./default.nix).c'
2.5

Another keyword often used is with, which basically adds the symbols of an attribute set into the scope of the inner expression.

# in default.nix
let
    a = -1;
    s = { a = 1; b = 2; };
    s' = with s; { c = b + 1; };  # the `with s` syntax simply avoids doing `s.b`
in
    with s; { d = a + b + s'.c; }  # d = -1 + 2 + (2+1)
$ nix-instantiate --eval -E '(import ./default.nix).d'
5

Note that the with keyword does not override existing variables in the scope.

The inherit keyword is also a convenient keyword, often used when creating attribute sets. It basically avoids repeating a = a or a = s.a inside the set definition:

# in default.nix
let
    x = 1 + 2;
    y = 2;
    s = { a = 4 / 0; b = "d"; };
in
    {
        inherit x y;  # equivalent to `x = x; y = y;`
        inherit (s) a b;  # equivalent to `a = s.a; b = s.b;`
    }
$ nix-instantiate --eval -E '(import ./default.nix)'
{ a = <CODE>; b = <CODE>; x = <CODE>; y = 2; }

Also, note how a, b and x attributes are not evaluated when you instantiate the attribute set: Nix is lazy and only evaluates expressions when needed. This is why defining a = 4 / 0 is fine, until you try to access it.

What is a derivation?

Remember Figure 1 ? The main reason we write .nix files is not to create general purpose programs, but to be able to describe and build packages. This is done thanks to the derivations.

But what is a derivation? It is a data structure (could be a json) stored in a .drv file inside the Nix store, containing all the information necessary to build a specific package. It is an intermediate representation between your source files (.nix) and your store outputs (programs and binaries in /nix/store).

This is a bit similar to object files (.o) in C for instance: they are an intermediate representation that you obtain from source files (.c), before linking them to obtain an executable.

Creating a derivation

You can obtain a derivation using the built-in derivation function (see the manual), that takes as input an attribute set, and outputs another attribute set:

$ nix-instantiate --eval -E 'derivation { name = "mypkg"; builder = "builder"; system = "mysystem"; }'
{ all = <CODE>; builder = "builder"; drvAttrs = { builder = "builder"; name = "mypkg"; system = "mysystem"; }; drvPath = <CODE>; name = "mypkg"; out = «repeated»; outPath = <CODE>; outputName = "out"; system = "mysystem"; type = "derivation"; }

This attribute set (with type = "derivation") uniquely defines a package. This is more or less what you will find inside the giant nixpkgs attribute set, when defining your package dependencies, or when combining packages together in an environment for instance.

An interesting thing to note is that, if you try to coerce a derivation (or any attribute set with an outPath attribute) to a string, you will obtain the outPath attribute:

$ nix-instantiate --eval -E 'let d = derivation { name = "mypkg"; builder = "builder"; system = "mysystem"; }; in "${d}"'
"/nix/store/12ly8qc4601sgnhjlygss43yinw6ahdj-mypkg"
$ nix-instantiate --eval -E 'let d = derivation { name = "mypkg"; builder = "builder"; system = "mysystem"; }; in d.outPath'
"/nix/store/12ly8qc4601sgnhjlygss43yinw6ahdj-mypkg"

Creating a working derivation

If you try to build the dummy derivation above, it will fail. Here is an example of a working derivation:

# hello.nix
let
    pkgs = import <nixpkgs>{};
in derivation {
    # --- Required attributes
    # Derivation name
    name = "mypkg";
    # The executable called to build the package
    builder = "${pkgs.bash}/bin/bash";
    # System on which the derivation can be built (nix-build will refuse to work otherwise)
    # e.g. "i686-linux" or "x86_64-darwin"
    system = builtins.currentSystem;

    # --- (Some) optional attributes
    # This list of args is passed to the builder executable above
    args = [ ./hello.sh ];
    # Any additional attributes is cast to a string passed
    # as an environment variable to the builder
    coreutils = pkgs.coreutils;
    mymsg = ./msg.txt;
}

This time, if we remove the --eval option, nix-instantiate will actually create a .drv file with the contents of our derivation:

$ nix-instantiate hello.nix
/nix/store/0smhrqz82a49vkkxfb5i4xplkggzkcw1-mypkg.drv
$ cat /nix/store/0smhrqz82a49vkkxfb5i4xplkggzkcw1-mypkg.drv
# not easily readable...
$ nix show-derivation /nix/store/0smhrqz82a49vkkxfb5i4xplkggzkcw1-mypkg.drv
{
    "/nix/store/0smhrqz82a49vkkxfb5i4xplkggzkcw1-mypkg.drv": {
        "args": [
            "/nix/store/2cx9h7pdb75hvm8a8bmsl9017aw2gjq2-hello.sh"
        ],
        "builder": "/nix/store/2198gb5ws3cyma9cxrx3clq6p83781kc-bash-5.1-p16/bin/bash",
        "env": {
            "builder": "/nix/store/2198gb5ws3cyma9cxrx3clq6p83781kc-bash-5.1-p16/bin/bash",
            "coreutils": "/nix/store/bwrmdh64v9b0ygl3bv3ys0x7fw4pykg5-coreutils-9.1",
            "mymsg": "/nix/store/gfis1dcq9sjplx85nfygi5bnb0qyanal-msg.txt",
            "name": "mypkg",
            "out": "/nix/store/rw75b9bvpn2f9id6j411xjcllpl9wxjy-mypkg",
            "system": "x86_64-darwin"
        },
        "inputDrvs": {
            "/nix/store/6wmsjv6zvq3ahdp42f5by31va2457zdy-coreutils-9.1.drv": [
                "out"
            ],
            "/nix/store/ng18jzn95n9fdiw2i334i6wjsffcfs4f-bash-5.1-p16.drv": [
                "out"
            ]
        },
        "inputSrcs": [
            "/nix/store/2cx9h7pdb75hvm8a8bmsl9017aw2gjq2-hello.sh",
            "/nix/store/gfis1dcq9sjplx85nfygi5bnb0qyanal-msg.txt"
        ],
        "outputs": {
            "out": {
                "path": "/nix/store/rw75b9bvpn2f9id6j411xjcllpl9wxjy-mypkg"
            }
        },
        "system": "x86_64-darwin"
    }
}

A couple interesting things to note here:

Let’s put ‘hello’ in the msg.txt file, and have the following hello.sh file:

# Add mkdir, chmod and other GNU coreutils to the PATH
export PATH="$coreutils/bin"
mkdir -p $out/bin
# Create our executable: a simple script that always outputs the content of mymsg
echo "$coreutils/bin/cat $mymsg" > $out/bin/hello
chmod +x "$out/bin/hello"

We can now build the package:

$ nix-build hello.nix  # or 'nix-store --realise /nix/store/0smhrqz82a49vkkxfb5i4xplkggzkcw1-mypkg.drv'
/nix/store/rw75b9bvpn2f9id6j411xjcllpl9wxjy-mypkg
$ /nix/store/rw75b9bvpn2f9id6j411xjcllpl9wxjy-mypkg/bin/hello
hello

Bingo ! 🎉🥳

Build and runtime dependencies

Using the --references option, we can query the immediate dependencies of an object in nix store:

$ nix-store -q --references /nix/store/0smhrqz82a49vkkxfb5i4xplkggzkcw1-mypkg.drv
/nix/store/2cx9h7pdb75hvm8a8bmsl9017aw2gjq2-hello.sh
/nix/store/ng18jzn95n9fdiw2i334i6wjsffcfs4f-bash-5.1-p16.drv
/nix/store/6wmsjv6zvq3ahdp42f5by31va2457zdy-coreutils-9.1.drv
/nix/store/gfis1dcq9sjplx85nfygi5bnb0qyanal-msg.txt
$ nix-store -q --references /nix/store/rw75b9bvpn2f9id6j411xjcllpl9wxjy-mypkg/
/nix/store/bwrmdh64v9b0ygl3bv3ys0x7fw4pykg5-coreutils-9.1
/nix/store/gfis1dcq9sjplx85nfygi5bnb0qyanal-msg.txt

Since the derivation is used to build the package, the dependencies of our .drv files correspond to our build dependencies. Inside, we can find all the store objects in the inputDrvs and inputSrcs.

The dependencies of our output package is more interesting: it contains only the store paths that we need to run our hello executable, i.e. our runtime dependencies. And we did not even have to specify them !

By looking inside the content of our build outputs (here, the /nix/store/rw75b9bvpn2f9id6j411xjcllpl9wxjy-mypkg/bin/hello executable), Nix was able to find references to nix store paths, and register them as runtime dependencies.

Here, /nix/store/rw75b9bvpn2f9id6j411xjcllpl9wxjy-mypkg/bin/hello is a simple text file, but it also works with binaries for instance. No matter the file format 4, Nix will scan the content of the file to find the runtime dependencies.

Conclusion

The Nix language allows us to declare derivations, that can then be used to build packages (or other outputs) in the nix store. A derivation is simply an attribute set containing all the required build inputs (builder executable, source files, environment variables, other dependencies), and is stored as a .drv file in the nix store once instantiated.

Resources



  1. Well, on the condition that your NIX_PATH is properly set… See this blog post for instance. 

  2. For instance, nix-env -i python3 to install a package or nix-env -u to update packages should not be used

  3. The derivation name is used when installing packages with nix-env -i mypkg, but this takes much longer (since it requires searching inside the entire nixpkgs collection) and can return multiple matches ! Instead, intalling packages with nix-env -iA nixpkgs.mypkg is recommended. It will use the attribute name in the nixpkgs attribute set (which might be different from the derivation name!), which is much faster (no need to evaluate nixpkgs entirely) and will return a unique result. 

  4. See the section Runtime dependencies of Nix Pills to learn more. Additional caveats can also be found in edolstra PhD thesis, section 3.4. Note that, for robustness, the scanner looks for the cryptographic hash only, not the full store path. Also, filenames stored in different encodings (e.g. UTF-16) or inside compressed executables could be a problem, but in practice this rarely happens.