Incremental Builds
This page explains what go2nix actually puts in the Nix store, what gets
reused on rebuilds, and how that differs from buildGoModule.
If you only want the API surface, see Builder API. If you want the step-by-step eval flow, see Default Mode.
The shape of a build
buildGoModule (nixpkgs)
┌──────────────────────────┐ ┌──────────────────────────┐
│ vendor FOD │ ──▶ │ app derivation │
│ (all modules, one hash) │ │ (go build ./..., 1 drv) │
└──────────────────────────┘ └──────────────────────────┘
Two derivations total. Any change to any .go file rebuilds the whole app
derivation; any go.sum change re-downloads the entire vendor tree.
go2nix (default mode)
┌────────────┐ ┌────────────┐ ┌────────────┐
│ module FOD │ │ module FOD │ ... │ module FOD │ one fixed-output derivation
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘ (FOD) per go.sum entry
│ │ │
┌─────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│ pkg drv │ │ pkg drv │ ... │ pkg drv │ one per Go package
│ (.a/.x) │ │ (.a/.x) │ │ (.a/.x) │ (third-party + local)
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
└───────────────┴──┬───────────────────┘
┌─────▼─────┐ ┌───────────┐
│ importcfg │ │ stdlib │ stdlib is independent
│ bundle │ │ drv │ (Go toolchain only)
└─────┬─────┘ └─────┬─────┘
└────────┬───────┘
┌─────▼─────┐
│ app drv │ link only
│ (link) │
└───────────┘
.a = compiled package archive; .x = export-data interface (see
Early cutoff below). The
importcfg is a file that maps each import path to its compiled .a
archive in the store — go tool compile and go tool link read it instead
of searching GOPATH.
For a non-trivial application this is hundreds to thousands of derivations instead of two — but almost all of them are reusable across rebuilds.
What gets cached
| Layer | One derivation per | Cache key (informally) | Rebuilds when |
|---|---|---|---|
| stdlib | Go toolchain | Go version + GOOS/GOARCH + tags | Go is bumped |
| module FOD | go.sum line | module path@version + NAR hash | that module is bumped in go.sum |
| third-party package | imported package | module FOD + import deps + gcflags | the module or any of its transitive deps change |
| local package | local Go package | filtered package directory + import deps | a .go file in that directory or a dep changes |
| importcfg bundle | app | the set of compiled package outputs | any package output changes |
| app | app | importcfg bundle + main package source | anything above changes |
Third-party module FODs and third-party package derivations are shared between every application in the flake (and across flakes, via the binary cache). Bumping a single module re-fetches one FOD and recompiles only the packages that transitively import it.
Local package derivations use a builtins.path-filtered source: only the
package’s own directory (plus its parent go.mod/go.sum, and any files
matched by //go:embed patterns in that package) is hashed, so editing
pkg/a/a.go does not change the input hash of the pkg/b derivation unless
b imports a. Embedded assets therefore participate in the per-package
cache key.
Rebuild propagation
When you edit a single local package, only the reverse-dependency cone of that package rebuilds:
- The edited package recompiles.
- Each package that imports it (directly or transitively) recompiles.
- The importcfg bundle and the final link derivation rebuild.
Packages outside the cone keep their existing store paths and are not rebuilt.
To get a feel for how big the cone is in your project, see Benchmarking.
Early cutoff with contentAddressed = true
By default, per-package derivations are input-addressed: if a package’s inputs change, every downstream derivation gets a new store path even if the compiled output happens to be byte-identical.
Setting contentAddressed = true opts into two coupled mechanisms:
- Floating-CA outputs. Per-package and importcfg derivations become
content-addressed, so a rebuild that produces a byte-identical
.aresolves to the same store path and short-circuits downstream rebuilds. ifaceoutput split. Each per-package derivation gains a secondifaceoutput containing only the export data (the.xfile produced bygo tool compile -linkobj). Downstream compiles depend onifaceinstead of the full.a, so changes to private symbols that don’t alter the package’s exported API don’t cascade. This mirrors the.xmodel used by Bazel’srules_go.
The two are coupled by design: CA without iface only short-circuits
comment-only edits, and iface without CA can’t cut off anything because
the input-addressed .x path still changes whenever src changes.
Requires the
ca-derivationsexperimental feature in Nix. The final binary stays input-addressed.Known limitation: adding the first package-level initializer to a previously init-free package still flips a bit in the
.xfile, so that particular edit cascades even though the API didn’t change. This is rare in practice.
The cost: eval time
go2nix trades build time for eval time. Every nix build evaluation:
- Calls
builtins.resolveGoPackages(the Nix plugin), which runsgo list -json -depsagainst your source tree. - Instantiates one derivation per package in the resulting graph.
For a large application (~3,500 packages) the warm-cache go list step
takes on the order of a few hundred milliseconds, and instantiation
adds a similar amount on top. This is the floor on every rebuild — even a
no-change rebuild — and is the main reason go2nix is overkill for small
single-binary projects.
The plugin call is impure (it reads GOMODCACHE), so the result is not
cached by the Nix evaluator across invocations. See
Nix Plugin for details.