Nix, Explained: A Crash Course in Reproducible Software
Modern software is a tangled web. Every program you run depends on a sprawling network of libraries, runtimes, and system tools. Most of the time, we don’t think about this web—it’s implicit, hidden inside our operating system’s file system. Nix is a tool that drags this hidden complexity into the light, making every single dependency explicit and verifiable. This is a foundational shift in how we can build, share, and deploy software.
This crash course breaks Nix down into four core concepts: the Nix Store, derivations, sandboxing, and the Nix language. Understanding how these pieces fit together is the key to unlocking Nix’s power.
The Nix Store: Software as a Graph Database
After you install Nix, you’ll find a directory at /nix/store. This isn't just a folder for binaries; it's a graph database where every piece of software is a node, and every dependency is an edge.
Each entry in the store has a unique path that looks like this:
h9bvv0qpiygnqykn4bf7r3xrxmvqpsrd-nix-2.3.3
The long string of characters at the beginning is a cryptographic hash. This hash is the crucial part. Nix is the only software allowed to write to the store, and once a node is written, it becomes immutable—its contents can never change. This immutability is a cornerstone of Nix’s reliability.
So, how do these nodes connect? An edge is created when a file in one store path references the literal text of another store path. For instance, if you run otool -L (or ldd on Linux) on the Nix binary itself, you’ll see paths to its libraries:
/nix/store/gk9l41kp852lddrvjx9cfkgxwjs3vls8-libsodium-1.0.16/lib/libsodium.23.dylib
This path is embedded in the binary’s text, which means Nix knows it points to the libsodium node. By scanning these references, Nix constructs a complete, explicit graph of every dependency.
You can query this graph directly with the nix-store --query command. For example, --references shows the direct dependencies of a node, while --referers shows what depends on it. The --requisites flag is particularly powerful: it computes the *transitive closure* of a node, which is the complete set of all dependencies, including their dependencies, and so on. A Ruby application might directly depend on a gem bundle, which in turn depends on nokogiri, which needs libxml2, which ultimately needs system-level libraries. The --requisites command captures this entire recursive chain.
To visualize just how deep this rabbit hole goes, you can even generate a graph of dependencies:
nix-store --query --graph $(which ruby) \
| nix run nixpkgs.graphviz -c dot > ruby.svg
The resulting image is a stark illustration of the “graphiness” of all software, even something as straightforward as a Ruby interpreter.
From “What” to “How”: Defining a Build
The store tells you *what* a piece of software is, but a *derivation* tells Nix *how to build it*. A derivation is the fundamental blueprint for a package in Nix. It’s a special file, ending in a .drv extension, that contains a complete specification for a build process.
The reason this is so revolutionary is that .drv files contain the exact cryptographic hashes of every single thing needed to perform the build. This includes the source code, all build dependencies, the compiler, and even the build scripts and environment variables. When Nix builds a package, it first constructs this complete derivation. The resulting output’s hash is then generated based on everything in the derivation. This ensures that any two people building the same derivation will get the exact same output—byte for byte—regardless of their local machine's state.
This eliminates a classic class of bugs. The old adage “it works on my machine” dies here, because your machine’s stale versions of packages can no longer influence a build. Nix already knows the exact inputs, and it fetches or creates them all from scratch in its controlled store.
Reproducibility Through Isolation
But a blueprint is only useful if the builder can't cheat. This is where sandboxing comes in. When Nix performs a build, it doesn't run the build process directly in your shell. Instead, it creates a hermetic, isolated environment for each package during its build phase.
Inside this sandbox, the process can only access the specific dependencies specified in the derivation—nothing else from your system is visible. This forces the build process to use only the required packages, ensuring that no stray system library can accidentally get linked in. This isolation is what makes Nix builds so consistent, reproducible, and declarative.
The Language of Creation
Finally, the Nix language is the glue that ties everything together. It is a purely functional, domain-specific language used to write the blueprints for your packages and every package in the nixpkgs repository. In Nix, you don't write shell commands to install things; you declare expressions that describe the derivation you want, and Nix takes care of the rest.
A Nix expression is not just a static list of instructions—it is a function that generates a derivation with the exact inputs we discussed. For example, you could define a derivation for 'my_app' by calling buildGoModule or python3Packages.buildPythonPackage, which are functions that implement for you the rules for how those targets are typically built. This makes the installation process predictable and declarative.
A Paradigm Shift for Development
The core paradigm shift isn't just knowing the package name; it's creating a portable definition. This has enormous implications for developer tooling. When a team uses Nix, every developer on a project loads a single shell with an entire development environment—including the correct versions of tools, compilers, and dependencies. Whether they are on a fresh machine or an old one, they get the same environment nix-shell provides.
The full picture is a system that transforms the process from “hope these set of tools install correctly” to “here is a fully defined, hermetic universe of how this software is built, which will work for everyone, everywhere.” That is the promise of Nix, and it’s why it’s being adopted by companies like Shopify for their core tooling.
Recipes in the Store: Derivations
The second building block is the Derivation. While Nix is the only process that can write into the Nix store, it needs explicit instructions on what to write and how. A Derivation is a special node in the store that contains those build instructions for one or more other nodes.
If you list the contents of /nix/store, you will see many items, but some have a .drv extension:
/nix/store/ynzfmamryf6lrybjy1zqp1x190l5yiy5-demo.drv
This file is a serialized format that Nix itself reads and writes. Nearly everything else in the store exists because a Derivation built it. A typical Derivation file looks like this:
$ cat /nix/store/ynzfmamryf6lrybjy1zqp1x190l5yiy5-demo.drv
Derive([("out","/nix/store/76gxh82dqh6gcppm58ppbsi0h5hahj07-demo","","")],[],[],"x86_64-darwin","/bin/sh",["-c","echo 'hello world' > $out"],[("builder","/nix/store/5arhyyfgnfs01n1cgaf7s82ckzys3vbg-bash-4.4-p23/bin/bash"),("name","demo"),("out","/nix/store/76gxh82dqh6gcppm58ppbsi0h5hahj07-demo"),("system","x86_64-darwin")])
Though not human-friendly, the format encodes two critical concepts:
- Every direct dependency required to build the Derivation is explicitly listed by its full store path (note the
bashbuilder above). - The hash portion of the Derivation's own store path is essentially a hash of the file's contents.
Because all dependencies are listed, and the path hashes the contents, any change to a dependency—such as a version bump—changes the contents, which changes the hash. That change then propagates upward: if dependency B changes, the hash of every Derivation that depends on B changes too, as do the hashes of all those Derivations' outputs. The effect cascades all the way up the dependency tree, invalidating everything that transitively depends on the changed node.
To see a Derivation in action, we can build it with nix-build:
$ nix-build /nix/store/ynzfmamryf6lrybjy1zqp1x190l5yiy5-demo.drv
/nix/store/76gxh82dqh6gcppm58ppbsi0h5hahj07-demo
$
The build produced a new store path. The hash in that path matches the one embedded in the Derivation file itself—the output path is pre-calculated as a stable hash of the derivation (and its output name, here the default "out"), but the content is not created until the build runs.
Dissecting a Derivation
The blob above decomposes into a small set of fields:
- outputs: Which nodes this build can produce.
- inputDrvs: Other Derivations that must finish building first.
- inputSrcs: Static files already in the store that this build needs.
- platform: The target CPU and OS, e.g., macOS or Linux.
- builder: The program executed to perform the build.
- args: Arguments passed to that builder program.
- env: Environment variables set for that builder.
In our example:
- outputs:
[("out","/nix/store/76gxh82dqh6gcppm58ppbsi0h5hahj07-demo","","")]—one output named"out". - inputDrvs:
[ ]—an empty list, since this toy has no dependencies besides its own builder. A realistic entry instead lists paths like/nix/store/4kgf3y9sm84jzcl3k3bn8vzl7fgafpm9-openssh-8.1p1.drvwith the requested output(s). - inputSrcs:
[ ]—again empty here; real builds often list helper scripts such as/nix/store/m00k69wikx3p7av28s0m40z9ipahw5ky-builder.sh. - platform:
"x86_64-darwin"—Nix supports multiple architectures, and compiled outputs are generally architecture-specific, so the target is declared explicitly. Because all dependencies are explicit, store entries can be copied between machines; the platform is just one more declared dependency. - builder:
"/nix/store/5arhyyfgnfs01n1cgaf7s82ckzys3vbg-bash-4.4-p23/bin/bash"—the program that must populate the output paths. - args:
["-c","echo 'hello world' > $out"]—effectively runningbash -c "echo 'hello world' > $out". - env:
[("builder","/bin/sh"),("name","demo"),("out","/nix/store/76gxh82dqh6gcppm58ppbsi0h5hahj07-demo"),("system","x86_64-darwin")]—each entry becomes an environment variable, which is how$outis available to the builder and why it matches the path in outputs.
Building the Derivation and inspecting its result confirms the design:
$ nix-build /nix/store/ynzfmamryf6lrybjy1zqp1x190l5yiy5-demo.drv
/nix/store/76gxh82dqh6gcppm58ppbsi0h5hahj07-demo
$ cat /nix/store/76gxh82dqh6gcppm58ppbsi0h5hahj07-demo
hello world
$
In short: a Derivation is a recipe whose execution is the only way new content enters the store.
Build Sandboxing
Explicit dependency declarations only work if builds cannot secretly reach for undeclared resources. Nix enforces this in two ways. First, it ships patched compilers and linkers that ignore default system locations such as /usr/lib. Second, and more substantially, builds run inside an actual sandbox.
The sandbox grants filesystem read access only to the store paths explicitly listed in the Derivation—nothing else. Effectively, anything built in the Nix store cannot have undeclared dependencies on anything outside it.
The Nix Language: Laziness and Purity
The final building block is the Nix language itself, used to construct Derivations. Two design properties stand out: it is lazy, and it is almost entirely free of side effects.
Laziness is best explained with code. Consider this attribute set:
data = {
a = 1;
b = functionThatTakesMinutesToRun 1;
};
Evaluating this definition takes almost no time. The value of b is not computed until something actually needs it. If we later write:
let
data = {
a = 1;
b = functionThatTakesMinutesToRun 1;
};
in data.a
the result is 1, and the expensive function is never invoked.
Notice also that the language itself appears to do very little real work beyond building data structures. That is intentional: the Nix language cannot perform general-purpose IO or other side effects. Its job is limited to describing derivations, which keeps evaluation deterministic and enables the lazy, dependency-driven model to work reliably at scale.
The Nix Language: Pure by Design
If you're used to general-purpose programming languages, Nix will feel conspicuously limited. It has no networking, no user input, no file writing, and no output—apart from limited debugging and tracing support. It cannot genuinely do anything in terms of interacting with the outside world. Almost everything you write in Nix is just data manipulation, shuffling values and functions around without any side effects.
The single exception is the derivation function. When you call it with the correct arguments, Nix writes a new <hash>-<name>.drv file into the Nix Store. This is the one and only observable side effect in the entire language.
Here's what a call to derivation looks like:
derivation {
name = "demo";
builder = "${bash}/bin/bash";
args = [ "-c" "echo 'hello world' > $out" ];
system = "x86_64-darwin";
}
Evaluating this in nix repl returns an object like:
«derivation /nix/store/ynzfmamryf6lrybjy1zqp1x190l5yiy5-demo.drv»
That returned object is essentially the same attribute set you passed in—name, builder, args, and system—plus a few extra fields, including drvPath, which is what gets printed in the REPL. Crucially, the file at that path was physically written to the Nix Store as a side effect of the call.
It's worth emphasizing how extreme this design is: essentially the only thing the Nix Language can actually do is create Derivations. Everything else is just a sophisticated way of building up the data that gets passed to derivation.
Derivations Reference Each Other
You'll notice that the example above references ${bash}. That's not a string interpolation trick—bash is itself another result of a derivation call. When Nix evaluates this expression, it generates instructions for building bash first, then uses that output path to construct the new derivation. This is exactly how dependencies between packages are expressed in Nix.
It's important to understand that the Nix Language never builds anything itself. It only produces Derivations. Separate Nix tools read those .drv files later and execute the builds. The language is best understood as a Domain Specific Language for declaring build tasks, not for performing them.
Nixpkgs as a Single Program
Nixpkgs—the default package repository—looks very different from what you might expect from a traditional package repository. It is a single Nix program that outputs a massive attribute set, where each attribute is a call to derivation. Its simplified structure looks like this:
{
ruby = derivation { ... };
python = derivation { ... };
nodejs = derivation { ... };
…
}
Because the Nix Language is lazily evaluated, only the attributes you actually force get their corresponding derivation files written to the store. To build Ruby, tools simply force evaluation of the ruby attribute, retrieve the generated .drv path, and then run a build command like nix-build against it.
This design lets Nixpkgs define tens of thousands of packages without paying any evaluation cost for packages you'll never use. It's a fundamentally different architecture from conventional package managers that treat the repository as a static collection of build scripts.
Getting a real grasp on Nix requires more than a single article—hands-on experimentation is almost necessary to internalize the shift in thinking it demands. For those interested in seeing how Nix fits into a production development workflow, Shopify has publicly shared recordings of its internal Nix training materials and developer tooling discussions.



