nix-build in under 100 lines
Published 2026-06-21 on Farid Zakaria's Blog
I’ve said before that Nix is a lie, and that underneath the ceremony Nix is really just an Input Output Machine.
The nix daemon feels like a black box. You type nix build and somewhere behind a Unix socket a privileged process does inscrutable things, and out the other end pops a path in /nix/store. 🪄
What if I told you the part everyone thinks is magic and that turning a derivation into a store path is nearly an exec ?
Let’s reimplement nix-build in under 100 lines of Go.
First off, What is a derivation, really?
A derivation (.drv) is just a build plan. Let’s instantiate the most boring one imaginable.
# hello.nix
derivation {
name = "hello";
system = builtins.currentSystem;
builder = "/bin/sh";
args = [ "-c" "echo 'Hello World' > $out" ];
}
$ nix derivation show $(nix-instantiate hello.nix)
{
"derivations": {
"gifgxsqfsjg8pxna1kv0nbzz1zvivs0b-hello.drv": {
"args": [
"-c",
"echo 'Hello World' > $out"
],
"builder": "/bin/sh",
"env": {
"builder": "/bin/sh",
"name": "hello",
"out": "/nix/store/ddmbmrgzcqqp0b8i9gmzav8zs8ch3176-hello",
"system": "x86_64-linux"
},
"inputs": { "drvs": {}, "srcs": [] },
"name": "hello",
"outputs": {
"out": {
"path": "ddmbmrgzcqqp0b8i9gmzav8zs8ch3176-hello"
}
},
"system": "x86_64-linux",
"version": 4
}
},
"version": 4
}
That’s the whole thing. A program to run (builder + args), an environment (env), the outputs it must produce, and the other derivations it depends on (inputDrvs), which in this case is empty. No magic 🪄.
So “realising” a derivation is just four steps:
- Realise its
inputDrvsfirst recursively. This is the build graph. - Scrub the environment down to a known set of variables.
- Set
$outto the store path the build must create. execthe builder and check it produced$out.
Here is the whole program in Go, in less than 100 lines (excluding comments 😉). You can find the source here.
Note I cheated a tiny bit and rather than writing a parser for Nix’s ATerm format, I leveraged
nix show derivationto get the JSON equivalent.
build.go
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
const store = "/nix/store"
type drv struct {
Args []string `json:"args"`
Builder string `json:"builder"`
Env map[string]string `json:"env"`
Inputs struct {
Drvs map[string]any `json:"drvs"`
} `json:"inputs"`
Outputs map[string]struct {
Path string `json:"path"`
} `json:"outputs"`
}
func exists(path string) bool { _, err := os.Stat(path); return err == nil }
// storePath makes a store path absolute; Nix's JSON uses bare basenames.
func storePath(p string) string {
if strings.HasPrefix(p, "/") {
return p
}
return store + "/" + p
}
// loadDrv shells out to Nix to turn a .drv into JSON, then decodes it.
func loadDrv(path string) (error, drv) {
data, err := exec.Command("nix", "--extra-experimental-features", "nix-command",
"derivation", "show", path).Output()
if err != nil {
return err, drv{}
}
var doc struct {
Derivations map[string]drv `json:"derivations"`
}
if err := json.Unmarshal(data, &doc); err != nil {
return err, drv{}
}
for _, d := range doc.Derivations {
return nil, d // exactly one entry: the derivation we asked for
}
panic("no derivation found for " + path)
}
// realise ensures the derivation's output exists, building its inputs first,
// and returns the default output's store path.
func realise(path string) (error, string) {
err, d := loadDrv(path)
if err != nil {
return err, ""
}
out := storePath(d.Outputs["out"].Path)
if exists(out) {
return nil, out // already built (this also memoises shared dependencies)
}
for dep := range d.Inputs.Drvs {
realise(storePath(dep)) // recurse: dependencies before dependents
}
fmt.Fprintln(os.Stderr, "building", out)
tmp, err := os.MkdirTemp("", "simple-nix-")
if (err != nil) {
return err, ""
}
defer os.RemoveAll(tmp)
// The build's entire environment: a few fixed vars, the derivation's own
// attributes, and one var per output (this is where $out comes from).
// These fixed variables and their values are specified by the Nix manual:
// https://github.com/NixOS/nix/blob/f8bb823a23bf6d62f4c8feb792a77702d7a49fe1/doc/manual/source/store/building.md?plain=1#L154
env := map[string]string{
"PATH": "/path-not-set", "HOME": "/homeless-shelter",
"NIX_STORE": store, "NIX_BUILD_TOP": tmp,
"TMPDIR": tmp, "TEMPDIR": tmp, "TMP": tmp, "TEMP": tmp,
}
for k, v := range d.Env {
env[k] = v
}
for name, o := range d.Outputs {
env[name] = storePath(o.Path)
}
cmd := exec.Command(d.Builder, d.Args...)
cmd.Dir, cmd.Stdout, cmd.Stderr = tmp, os.Stderr, os.Stderr
for k, v := range env {
cmd.Env = append(cmd.Env, k+"="+v)
}
if err := cmd.Run(); err != nil {
return err, ""
}
if !exists(out) {
panic(fmt.Sprintf("builder did not produce %s", out))
}
return nil, out
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: simple-nix <file.drv> ...")
os.Exit(2)
}
for _, arg := range os.Args[1:] {
fmt.Println(realise(arg))
}
}
That’s it. Does it work?
$ go build -o simple-nix .
$ ./simple-nix $(nix-instantiate hello.nix)
building /nix/store/ddmbmrgzcqqp0b8i9gmzav8zs8ch3176-hello
/nix/store/ddmbmrgzcqqp0b8i9gmzav8zs8ch3176-hello
$ cat /nix/store/ddmbmrgzcqqp0b8i9gmzav8zs8ch3176-hello
Hello World
We can even build a real-world derivation.
$ ./simple-nix $(nix eval nixpkgs#hello --raw)
Using versionCheckHook
Running phase: unpackPhase
unpacking source archive /nix/store/wj7phsmi7ncidl8k00p489krqss7n9sd-hello-2.12.3.tar.gz
source root is hello-2.12.3
setting SOURCE_DATE_EPOCH to timestamp 1773804383 of file "hello-2.12.3/ChangeLog"
Running phase: patchPhase
Running phase: updateAutotoolsGnuConfigScriptsPhase
Updating Autotools / GNU config script to a newer upstream version: ./build-aux/config.sub
Updating Autotools / GNU config script to a newer upstream version: ./build-aux/config.guess
...
So what is missing?
Quite a lot, honestly but none of it is the part that turns a derivation into a path.
- Sandboxing: Nix runs the builder in a mount/network/PID namespaces for security and hermiticity.
- The database: Nix records every valid path and its references in a SQLite db. We just check if the file exists.
- Substitution: Nix asks a binary cache if the derivation was alreay built.
- Everything else: Multiple output paths, support for fixed-output derivations (
fetchurl), garbage collection, etc.
The beauty of Nix is the derivation is a pure function. Getting the store path is not magic. It’s exec with a clean environment. Everything else, mostly is bookkeeping and security.
Improve this page @ 212fbd5
The content for this site is
CC-BY-SA.