Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

LayerOne derivation perCache key (informally)Rebuilds when
stdlibGo toolchainGo version + GOOS/GOARCH + tagsGo is bumped
module FODgo.sum linemodule path@version + NAR hashthat module is bumped in go.sum
third-party packageimported packagemodule FOD + import deps + gcflagsthe module or any of its transitive deps change
local packagelocal Go packagefiltered package directory + import depsa .go file in that directory or a dep changes
importcfg bundleappthe set of compiled package outputsany package output changes
appappimportcfg bundle + main package sourceanything 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:

  1. The edited package recompiles.
  2. Each package that imports it (directly or transitively) recompiles.
  3. 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 .a resolves to the same store path and short-circuits downstream rebuilds.
  • iface output split. Each per-package derivation gains a second iface output containing only the export data (the .x file produced by go tool compile -linkobj). Downstream compiles depend on iface instead of the full .a, so changes to private symbols that don’t alter the package’s exported API don’t cascade. This mirrors the .x model used by Bazel’s rules_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-derivations experimental 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 .x file, 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:

  1. Calls builtins.resolveGoPackages (the Nix plugin), which runs go list -json -deps against your source tree.
  2. 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.