Introduction
⚠️ Experimental — APIs and lockfile formats may change without notice.
go2nix is a Nix-native Go builder with per-package derivations and
fine-grained caching. It is an alternative to nixpkgs buildGoModule for
projects that want more visibility and reuse than the usual “fetch all
modules, then build everything in one derivation” model.
In Go, a module is the versioned unit you depend on (one go.mod, one
entry in go.sum); a package is a single importable directory of .go
files. One module typically contains many packages. go2nix locks modules but
builds packages:
- the lockfile pins modules, not the package graph
- the builder discovers the package graph and compiles it at package granularity
- Nix can cache and rebuild individual Go packages, not just the whole app
This works especially well for monorepos and multi-package repositories that want to maximize Nix store reuse. When only part of the Go package graph changes, go2nix reuses the rest of the graph instead of rebuilding the whole application derivation.
Every box above is a derivation; Incremental Builds explains what each one is keyed on.
If you just want the simplest way to package a Go program in nixpkgs,
buildGoModule is still the default choice. go2nix is aimed at cases where
per-package reuse and explicit graph handling are worth the extra machinery.
Quick start
Heads up: the default builder requires the go2nix Nix plugin to be loaded into your evaluator (Nix 2.34 or newer). Without it,
nix buildfails witherror: attribute 'resolveGoPackages' missing.
Getting Started goes from an empty directory to a built binary, with the output of every step. In short:
nix run github:numtide/go2nix -- generate .writesgo2nix.toml, one hash per module (optional:goLock = nullderives them fromgo.sum).go2nix.lib.mkGoEnv { … }in your flake gives you a scope, andgoEnv.buildGoApplication { src = ./.; goLock = ./go2nix.toml; pname = …; }describes the application.nix build, with the plugin loaded through--option plugin-filesornix.conf.
Where to next
- Getting Started — the first build, step by step
- Architecture — how the builder works
- Builder Modes — default vs experimental
- Incremental Builds — what gets cached
- Builder API — full attribute reference
- Troubleshooting — when something doesn’t work
Getting Started
From an empty directory to a built binary, one step at a time, with the default builder. The module, the lockfile, the derivation names and the rebuild counts below are taken from a real build of exactly these files.
What you need
- Nix with flakes enabled, on
x86_64-linux,aarch64-linuxoraarch64-darwin. - The go2nix Nix plugin loaded into the evaluator; step 4 shows how. It builds against Nix 2.34 or newer.
- A
goonPATHfor the two steps that talk to the module proxy: creating the module andgo2nix generate.nix shell nixpkgs#gois enough. The build itself uses the Go from your nixpkgs, not this one.
1. A module
mkdir my-app && cd my-app
go mod init example.com/my-app
// main.go
package main
import "github.com/fatih/color"
func main() {
color.Green("hello from go2nix")
}
go get github.com/fatih/color@v1.18.0
go mod tidy
go.mod now requires four modules, one direct and three indirect:
module example.com/my-app
go 1.26.5
require github.com/fatih/color v1.18.0
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.25.0 // indirect
)
One thing to watch in that file: go mod init wrote the version of the go
you ran into the go line. Keep it at or below the Go of the nixpkgs you
build with (nix eval --raw nixpkgs#go.version). A newer directive makes the
evaluation-time go list try to download that toolchain, and it stops with
go: download go1.99.0 for linux/amd64: toolchain not available. Using
nix shell nixpkgs#go from the same nixpkgs for this step avoids the
question.
2. Pin the modules, or don’t
nix run github:numtide/go2nix -- generate .
(timestamps removed)
level=INFO msg="cache loaded" mods=0
level=INFO msg="collecting modules" dir=.
level=INFO msg="modules found" count=4
level=INFO msg=hashing todo=4 cached=0
level=INFO msg="writing lockfile" mods=4 path=go2nix.toml
# go2nix lockfile v2. Generated by go2nix. Do not edit.
[mod]
"github.com/fatih/color@v1.18.0" = "sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY="
"github.com/mattn/go-colorable@v0.1.13" = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8="
"github.com/mattn/go-isatty@v0.0.20" = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
"golang.org/x/sys@v0.25.0" = "sha256-PXZ9EQZ7SFpcL7d3E1+KGTxziYlHEIZPfoXEbnaVD3I="
One hash per module, nothing about packages. Commit it next to go.mod.
go2nix check . compares it with go.mod and says nothing when they agree
(exit status 0).
The lockfile is optional in default mode. With goLock = null the plugin
reads go.sum and computes the same hashes while Nix evaluates, caching them
under ~/.cache/go2nix/nar/. The derivations come out identical either way:
building this module both ways fetched and compiled every dependency once.
What the lockfile adds is a list of hashes that lives in the repository and
is reviewed like any other change, plus a check of go.mod against it at
build time; without one the hashes are recomputed from go.sum and the
module cache. See Lockfile Format.
3. flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
go2nix = {
url = "github:numtide/go2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, go2nix, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
goEnv = go2nix.lib.mkGoEnv {
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix;
};
in
{
packages.${system}.default = goEnv.buildGoApplication {
pname = "my-app";
version = "0.1.0";
src = ./.;
goLock = ./go2nix.toml; # or null
};
};
}
mkGoEnv makes a scope: a Go toolchain, the go2nix CLI the builders call,
and the builder functions that share them. buildGoApplication describes one
application in it. All of its attributes are in the
Builder API.
If the directory is a git repository, git add the files first: a flake only
sees tracked files, and a go.mod Nix cannot see is a go.mod that is not
there.
4. Load the plugin
buildGoApplication calls builtins.resolveGoPackages, which only exists
once the plugin is loaded. For one command:
plugin="$(nix build --no-link --print-out-paths \
github:numtide/go2nix#go2nix-nix-plugin)/lib/nix/plugins/libgo2nix_plugin.so"
nix build --option plugin-files "$plugin"
Permanently: add plugin-files = <that path> to nix.conf, or on NixOS set
nix.settings.plugin-files from
inputs.go2nix.packages.${pkgs.system}.go2nix-nix-plugin.
To see whether an evaluator has it:
nix eval --option plugin-files "$plugin" --expr 'builtins ? resolveGoPackages'
# true
Without it, evaluation stops at attribute 'resolveGoPackages' missing. The
plugin has to be built against the Nix that loads it; see
Nix Plugin.
5. Build
nix build --option plugin-files "$plugin"
./result/bin/my-app
# hello from go2nix
result/bin/ holds one binary per main package, named after its directory,
or after pname for the package at the module root. What Nix built to get
there, by derivation name:
| Derivation | What it is |
|---|---|
go-stdlib-… | the standard library, compiled once per toolchain and goEnv |
gomod-github.com-fatih-color-v1.18.0 and three more | one fetch per module |
gopkg-github.com-fatih-color-v1.18.0, gopkg-github.com-mattn-go-colorable-v0.1.13, gopkg-github.com-mattn-go-isatty-v0.0.20, gopkg-golang.org-x-sys-unix-v0.25.0 | one compile per third-party package that main.go reaches: x/sys is one package here, not the module |
golocal-example.com-my-app | one compile per package of yours |
my-app-deps-importcfg, my-app-test-deps-importcfg | the import maps the link and the tests read |
my-app-0.1.0 | compiles main, links, runs the tests |
Incremental Builds explains what each one is keyed on.
6. Change something
Edit main.go and build again. Two derivations run:
golocal-example.com-my-app and my-app-0.1.0. Nothing is fetched and no
dependency is recompiled.
Import another package from a module you already require. Nothing to regenerate: the lockfile pins modules, and the package graph is rediscovered on every evaluation.
Add or bump a module.
go get github.com/spf13/cobra@latest
go mod tidy
nix run github:numtide/go2nix -- generate .
generate reuses the hashes already in go2nix.toml and only downloads what
is new. If you forget, evaluation fails with
error: attribute '"github.com/spf13/cobra@<version>"' missing: the package
graph uses a module the lockfile does not have. (With goLock = null there is
nothing to forget.)
Where next
- Recipes: a module inside a larger repository, private modules, cgo, static binaries.
- Builder API: every attribute, tests, cross-compilation, private modules.
- Test Support: what
doCheckruns. - Package Overrides: cgo packages that need system libraries.
- Builder Modes: the default builder against the experimental one.
- Troubleshooting: the errors above and the rest.
Recipes
Worked setups for the situations that come up after the first build. The derivation counts and “nothing rebuilds” claims below were checked by building the layouts shown.
A module inside a larger repository
repo/
├── flake.nix
├── docs/ web/ … # not Go
├── libs/greet/ # module example.com/libs/greet
│ ├── go.mod
│ └── greet.go
└── services/api/ # module example.com/api
├── go.mod # replace example.com/libs/greet => ../../libs/greet
├── go2nix.toml
├── cmd/api/main.go
└── internal/handlers/…
packages.api = goEnv.buildGoApplication {
pname = "api";
version = "0.1.0";
src = ./.; # the repository root
modRoot = "services/api"; # where go.mod is
subPackages = [ "cmd/api" ]; # relative to modRoot
goLock = ./services/api/go2nix.toml;
};
src has to contain everything the module’s filesystem replace directives
point at, which is why it is the repository root and not ./services/api.
result/bin/api is named after the directory of the main package.
Generate the lockfile from inside the module, because -o is relative to
where you run the command:
cd services/api && nix run github:numtide/go2nix -- generate .
Modules reached through a filesystem replace are not in the lockfile: their
packages are compiled as local packages (golocal-example.com-libs-greet),
straight from src.
What an unrelated change costs. Nothing is rebuilt. Every package
derivation is keyed on its own directory, and the final derivation on a
filtered copy that holds only the packages in the build and the go.mod and
go.sum of the modules involved. Editing docs/ and web/ and adding a whole
new services/billing/ module to the layout above gives the same derivation,
store path for store path. Nix still evaluates, which means it still runs
go list; see Incremental Builds.
Do you need to narrow src? Not for caching. Building with src
restricted to the two module directories produces the identical derivation.
What narrowing saves is the copy of the repository into the store when Nix
evaluates a path outside a flake. If that matters,
helpers.goModLocalReplaceDirs follows the replace directives for you:
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions
(goEnv.helpers.goModLocalReplaceDirs ./services/api);
};
srcFilter = path: type: … is the other tool: a predicate applied to every
per-package copy and to the final one, for files that sit inside package
directories and should not count (editor backups, generated reports).
Builder API has both.
Several services. Build them all from one goEnv. Third-party packages
are derivations of their own, so api and billing compile
golang.org/x/sys/unix once between them, whatever their lockfiles say, as
long as they agree on the version, tags and gcflags.
Private modules, end to end
Module downloads happen in three places, each with its own environment. A private module has to be reachable in all three.
| Where | When | Sees |
|---|---|---|
go2nix generate | you run it | your whole environment: GOPROXY, GOPRIVATE, ~/.netrc, your git and its credentials. It turns the checksum database off itself. |
go list, in the plugin | every evaluation | GOMODCACHE, GOPATH, HOME (so ~/.netrc), GOPROXY, NETRC and TLS certificate variables of whatever evaluates; goProxy overrides GOPROXY. Not GOPRIVATE, and no go env -w settings. |
| module fetch derivations | the build | GOPROXY and NETRC of whatever runs the build (the daemon, a remote builder), unless goProxy is set; the scope’s netrcFile; no git. |
The setup that works everywhere is a Go module proxy that serves your private modules, plus credentials for it:
goEnv = go2nix.lib.mkGoEnv {
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix;
netrcFile = ./ci/netrc; # machine goproxy.example.com login … password …
};
packages.api = goEnv.buildGoApplication {
# …
goProxy = "https://goproxy.example.com,https://proxy.golang.org";
};
goProxyputs the proxy in the fetch derivations themselves, so it does not depend on how the daemon was started, and it is also what the evaluation-timego listuses.netrcFileends up in the store, readable by every user of the machine and of any cache you push to. Use a token that can only read those modules. See Builder API.- For evaluation, the machine needs the same credentials in
~/.netrc(orNETRC), or the modules already inGOMODCACHE. A CI job that runsgo mod downloadbeforenix buildsatisfies that. - Fetching straight from a private Git host (
GOPRIVATE,direct) works forgenerateon your machine and nowhere else: the fetch derivations have nogit. Put a proxy in front (Athens, Artifactory, the forge’s own Go registry).
When a fetch fails with a 404 or a 401, the derivation is named
gomod-<module>-<version>, and its log is go mod download’s own output.
cgo packages that need a system library
Package Overrides has the examples. Finding which
package to override is the part that is not obvious: the build fails in a
derivation named gopkg-<import path>-<version>, and that import path, with
the dashes turned back into slashes, is the key. nix log on that derivation
shows the missing header or pkg-config complaint.
packageOverrides."github.com/go-piv/piv-go/piv" = {
nativeBuildInputs = [ pkgs.pkg-config pkgs.pcsclite ];
};
A key that is a module path applies to every cgo package of that module. The
libraries a cgo package links against also have to be present when the final
binary is linked; the builder adds each override’s nativeBuildInputs to the
application derivation for that.
A static binary for a container
goEnv = go2nix.lib.mkGoEnv {
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix;
goEnv.CGO_ENABLED = "0";
};
packages.api = goEnv.buildGoApplication {
# …
ldflags = [ "-s" "-w" ];
};
CGO_ENABLED belongs in the scope’s goEnv because the standard library is
compiled once per scope and has to agree with the packages built on top of
it. With cgo off, net and os/user use their pure-Go implementations and
the binary has no dynamic dependencies; its closure is the binary (the builder
refuses a result that still refers to the Go toolchain unless
allowGoReference is set).
Package Overrides
packageOverrides lets you customize the per-package derivation for
specific Go packages — typically to give a cgo package the C toolchain
inputs it needs.
This page covers the lookup rules, the supported keys, and worked examples. For the one-line summary, see the Builder API table.
Lookup order
packageOverrides is keyed by import path or module path. When
compiling a package, the builder looks up an override in this order:
- The package’s exact import path
(e.g.
"github.com/diamondburned/gotk4/pkg/core/glib"). - The package’s module path (e.g.
"github.com/diamondburned/gotk4/pkg"). - Otherwise, no override.
In default mode only the first match is used; entries are not merged.
(The experimental builder applies both: the nativeBuildInputs of the
import-path entry and of the module-path entry are added together.)
Module-path keys are convenient when one module ships many cgo packages that
all need the same system libraries.
In default mode the module-path fallback applies to local packages too: a key equal to your main module’s path, or to the path of a module you
replacewith a directory, is applied to every local package of that module. In experimental mode local packages are matched by exact import path only.
Supported keys
| Key | Type | Default mode | Experimental mode | Notes |
|---|---|---|---|---|
nativeBuildInputs | list of derivations | cgo packages only | yes | Default mode: added to the per-package derivation’s nativeBuildInputs. Experimental mode: their bin, lib/pkgconfig and include directories are put on PATH, PKG_CONFIG_PATH and CGO_CFLAGS, and a derivation’s dev output is added automatically |
env | attrset | yes | no | Extra environment variables on the per-package derivation |
srcOverlay | derivation or path | yes | no | Contents are layered onto the package’s source directory at build time |
In default mode, nativeBuildInputs from every entry in packageOverrides
(regardless of whether the key matched a package in the graph) are also
collected and added to the final application derivation, so headers and
libraries are available at link time as well.
nativeBuildInputs is cgo-only
Non-cgo packages are compiled with a raw builder (rawGoCompile) that
bypasses stdenv entirely and hardcodes PATH — nativeBuildInputs would
silently do nothing. When the key is that package’s exact import path the
builder rejects it instead; when the entry matched through the module path,
which legitimately covers cgo and non-cgo packages alike, it is dropped for
the non-cgo ones without an error. For the error message and fix list, see
Troubleshooting.
Example: single cgo package
dotool has one local cgo package wrapping libxkbcommon via pkg-config:
goEnv.buildGoApplication {
pname = "dotool";
version = "1.6";
src = ./.;
goLock = ./go2nix.toml;
packageOverrides = {
"git.sr.ht/~geb/dotool/xkb" = {
nativeBuildInputs = [
pkgs.pkg-config
pkgs.libxkbcommon
];
};
};
}
Example: many cgo packages from one module
gotk4 ships dozens of cgo packages under one module. Key the override by
the module path so it applies to every package in that module:
let
gtkDeps = {
nativeBuildInputs = [
pkgs.pkg-config
pkgs.glib
pkgs.cairo
pkgs.gobject-introspection
pkgs.gdk-pixbuf
pkgs.pango
pkgs.gtk3
pkgs.at-spi2-core
pkgs.gtk-layer-shell
];
};
in
goEnv.buildGoApplication {
pname = "nwg-drawer";
version = "0.7.4";
src = ./.;
goLock = ./go2nix.toml;
packageOverrides = {
"github.com/diamondburned/gotk4/pkg" = gtkDeps;
"github.com/diamondburned/gotk4-layer-shell/pkg" = gtkDeps;
};
}
Example: env
packageOverrides = {
"github.com/example/pkg" = {
env = {
CGO_CFLAGS = "-I${libfoo.dev}/include";
};
};
};
env is default-mode only. The experimental builder synthesizes
derivations at build time inside go2nix resolve and can only forward
store paths, so it rejects env (and any other unknown key) at eval time.
Example: srcOverlay for build-time-generated //go:embed content
When a package embeds files that are themselves build outputs — a bundled
SPA under //go:embed all:dist, generated protobuf descriptors, etc. —
srcOverlay lets you supply them as a derivation that is layered onto the
package’s source directory at build time:
packageOverrides = {
"example.com/app/ui" = {
srcOverlay = pkgs.runCommand "ui-dist" { } ''
mkdir -p $out/dist
cp -r ${uiBundle}/* $out/dist/
'';
};
};
The overlay is cp -rL’d onto a writable copy of the package’s source
before go2nix compile-package runs, so ResolveEmbedCfg sees the
generated files. This is a build-time input of that one compile derivation
only — it does not flow into src at evaluation time, so it does not
trigger IFD and does not invalidate sibling packages.
Keep a placeholder file in the source tree (e.g. ui/dist/.gitkeep) so
the eval-time go list doesn’t error on a //go:embed pattern with zero
matches; the overlay then replaces or augments it at build time.
The doCheck testrunner applies the same overlay before resolving
//go:embed patterns and recompiling for tests, so tests see the
overlaid content too.
Test Support
go2nix runs Go tests during the check phase of default mode builds. Tests are
compiled and executed per-package, approximating go test semantics for
supported cases (see Limitations below).
Enabling tests
Tests are controlled by doCheck:
goEnv.buildGoApplication {
src = ./.;
goLock = ./go2nix.toml;
pname = "my-app";
version = "0.1.0";
doCheck = true;
}
doCheck defaults to true (matching buildGoModule). The filtered
mainSrc for the final derivation includes local replace targets outside
modRoot, so test discovery works for sibling-replace layouts without
overrides. See the Builder API table for the other
buildGoApplication defaults.
What gets tested
The test runner runs the tests of the local packages that are part of the
build: the packages subPackages reach, including packages of sibling
modules behind a filesystem replace, plus local helper packages that only
their tests import. A package with _test.go files that nothing in
subPackages reaches is skipped (the test binary could not be linked
without compiling it, and nothing else asked for it). Third-party packages
are not tested.
Each testable package goes through these steps:
- Internal test compilation — library source files +
_test.gofiles in the same package are compiled together into a single archive that replaces the library archive. - Dependent recompilation — local packages that transitively depend on the package under test are recompiled against the test archive so the dependency graph stays consistent (otherwise the xtest would link two copies of the package — one with test helpers, one without).
- External test compilation —
_test.gofiles in the*_testpackage (xtests) are compiled as a separate package that imports the internal test archive. - Test main generation — a
_testmain.gois generated that registers allTest*,Benchmark*,Fuzz*, andExample*functions. - Link and run — the test binary is linked and executed in the package’s source directory.
Internal tests vs external tests (xtests)
Go has two kinds of test files, both supported:
-
Internal tests (
package foo):_test.gofiles in the same package. These can access unexported identifiers. They are compiled together with the package’s regular source files into a single archive. -
External tests (
package foo_test):_test.gofiles in the_testpackage. These can only access exported identifiers and test the public API. They are compiled as a separate package (foo_test) that importsfoo.
When a package has both, the internal test archive replaces the original library archive, and any local dependents reachable from the xtest’s import graph are recompiled to see the replacement.
Test-only dependencies
When doCheck = true, the Nix plugin runs a second
go list -deps -test pass
to discover third-party packages that are only reachable through test
imports (e.g., github.com/stretchr/testify). These are built as separate
testPackages derivations and included in a testDepsImportcfg bundle
that is a superset of the build importcfg. The same pass finds local packages
that only tests import (an internal/testutil, say): they get their own
compile derivations like any other local package, and their own tests run
too.
Test-only dependencies do not touch the per-package compile derivations or the build importcfg. The check phase, however, is part of the same derivation that links the binary, so bumping a test-only dependency changes that derivation: it relinks and re-runs the tests.
//go:embed in tests
Embed directives in test files are supported:
-
TestEmbedPatterns(from internal_test.gofiles) are resolved and their files are symlinked into the internal test source directory alongside the package’s regular embed files. The embed configs are merged. -
XTestEmbedPatterns(from external_test.gofiles) are resolved and symlinked into the xtest source directory with their own embed config.
extraMainSrcFiles
Tests run against a filtered copy of src that keeps only what the build
needs: for every local package in the build, the files go list reports
(.go including _test.go, assembly, C/C++ and header files, .syso),
its resolved //go:embed targets and its testdata/ directory; plus
go.mod/go.sum at modRoot and at the root of each replaced sibling
module. srcFilter applies on top. Anything else is dropped so unrelated
edits don’t invalidate the test derivation. (With doCheck = false the copy
shrinks to the main packages.)
A test that reads a file at runtime without //go:embed and outside
testdata/ — e.g. os.ReadFile("../config.yaml") — will not find it.
Prefer moving such fixtures under testdata/. When that isn’t practical,
list the paths in extraMainSrcFiles:
goEnv.buildGoApplication {
src = ./.;
goLock = ./go2nix.toml;
pname = "my-app";
version = "0.1.0";
extraMainSrcFiles = [ "config.yaml" "internal/svc/fixtures" ];
}
Each entry is relative to src (not modRoot). A directory entry includes
its full subtree, and a trailing / is tolerated. An entry that does not
exist under src fails evaluation.
checkFlags
Extra flags passed to the test binary (not to go test, since go2nix
compiles and runs tests directly):
goEnv.buildGoApplication {
src = ./.;
goLock = ./go2nix.toml;
pname = "my-app";
version = "0.1.0";
checkFlags = [ "-test.v" "-test.count=1" ];
}
The flags are handed to the test binary as they are, so they take the
testing package’s own spelling: -test.v, -test.run, -test.count,
-test.bench, -test.timeout. The short forms (-v, -run) are go test
rewrites and the binary rejects them.
Limitations
- Default mode only. The experimental builder does not run tests.
- No per-package test caching. All local tests re-run whenever the final
app derivation rebuilds; go2nix does not skip individual test packages
whose inputs are unchanged (unlike
go test’s cache). - Third-party tests are not run, and neither are local packages outside
the
subPackagesclosure (see What gets tested). - The working directory is read-only. Each test runs in its package’s
directory inside the filtered source copy, which is a store path: a test
that writes next to its sources fails. Use
t.TempDir(). - No
go testextras. No coverage instrumentation, novetpass, no default 10-minute timeout, and packages are tested one after another. ldflagsdo not reach test binaries; they are linked with the importcfg only.
Migrating
How a buildGoModule or gomod2nix expression translates, attribute by
attribute, and what behaves differently afterwards. The buildGoModule side
is as of nixpkgs’ pkgs/build-support/go/module.nix.
From buildGoModule
# before
pkgs.buildGoModule {
pname = "my-app";
version = "0.1.0";
src = ./.;
vendorHash = "sha256-…";
subPackages = [ "cmd/server" ];
ldflags = [ "-s" "-w" "-X main.version=0.1.0" ];
tags = [ "netgo" ];
env.CGO_ENABLED = 0;
}
# after
goEnv.buildGoApplication {
pname = "my-app";
version = "0.1.0";
src = ./.;
goLock = ./go2nix.toml; # generated; or null
subPackages = [ "cmd/server" ];
ldflags = [ "-s" "-w" "-X main.version=0.1.0" ];
tags = [ "netgo" ];
}
with, once per flake,
goEnv = go2nix.lib.mkGoEnv {
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix;
goEnv.CGO_ENABLED = "0"; # the standard library is compiled with it too
};
and the plugin loaded. See Getting Started for both.
buildGoModule | go2nix | Notes |
|---|---|---|
pname, version, src, meta, passthru | the same | |
vendorHash | goLock = ./go2nix.toml, or goLock = null | One hash per module instead of one over the whole vendor tree; go2nix generate . writes the file. Bumping one module re-fetches one module. |
proxyVendor, deleteVendor, goSum, overrideModAttrs | none needed | There is no vendor step. Every module is its own fetch derivation, made with go mod download. A vendor/ directory in src is ignored. To change how modules are fetched, override fetchers.fetchGoModule in the scope. |
modRoot | modRoot | Same meaning. Default ".". |
subPackages | subPackages | The default differs. buildGoModule builds every directory that has Go files when subPackages is not set; go2nix builds [ "." ], the package at modRoot. List your main packages. |
excludedPackages | none | Say what to build with subPackages instead. |
ldflags | ldflags | Passed to go tool link. |
tags | tags | Per build, not on mkGoEnv. |
GOFLAGS | none | Nothing here runs go build, so there is no GOFLAGS to read. -trimpath is always on; compiler flags go in gcflags. |
env.CGO_ENABLED | CGO_ENABLED, or goEnv.CGO_ENABLED on the scope | The scope’s value also decides how the standard library is compiled. |
other env.* | env for the final derivation; goEnv on the scope for what the toolchain must see (GOEXPERIMENT, GOFIPS140, GOOS, GOARCH) | |
nativeBuildInputs, buildInputs (for cgo) | packageOverrides.<import path>.nativeBuildInputs | The C compiler runs in the derivation of the package that has the cgo code, so that is where pkg-config and the library go. See Package Overrides and how to find the package. Top-level inputs reach the final link only. |
doCheck | doCheck | Both default to true. go2nix tests the local packages that are part of the build, not every directory with a _test.go. |
checkFlags = [ "-v" "-run" "X" ] | checkFlags = [ "-test.v" "-test.run" "X" ] | The flags go to the test binary itself, not through go test. |
buildTestBinaries | none | |
allowGoReference | allowGoReference | Same meaning and default. |
enableParallelBuilding | none | Parallelism is Nix building independent package derivations. |
preBuild, postInstall, other phases and hooks | passed through | They run in the final derivation, which compiles the main packages, links and tests. They cannot affect how a dependency or a library package of yours is compiled: that happened in another derivation. |
go generate in preBuild | packageOverrides.<import path>.srcOverlay, or commit the generated files | A package is compiled from its directory in src, plus an optional overlay derivation laid over it. |
buildGoModule.override { go = …; } | mkGoEnv { go = …; } | The toolchain is a property of the scope. |
pkgsCross.….buildGoModule | pkgsCross.….callPackage into mkGoEnv, or goEnv.GOOS/GOARCH | See Cross-compilation. |
What changes
- One derivation becomes many.
nix buildoutput,nix log, andnix why-dependsnow talk aboutgopkg-…andgolocal-…derivations. A compile error is in the log of the package that failed; see Debugging a build. - Evaluation does work. The plugin runs
go listevery time Nix evaluates the expression, so the modules must be inGOMODCACHEor downloadable there, and evaluation is slower than reading a hash. In exchange a change rebuilds only what depends on it. - The evaluator needs the plugin, on every machine that evaluates: yours,
CI, anything that runs
nix flake check. Machines that only build (remote builders, the daemon) need nothing. - Tests run from a read-only copy of the sources, one package after the
other, with no
go vetpass. See Test Support. vendorHash = null(dependencies vendored in the tree) has no equivalent: go2nix fetches modules itself.
If none of this buys you anything, because the project is small and rebuilds
are rare, buildGoModule is the simpler tool.
From gomod2nix
# before
gomod2nix.buildGoApplication {
pname = "my-app";
version = "0.1.0";
pwd = ./.;
src = ./.;
modules = ./gomod2nix.toml;
}
# after
goEnv.buildGoApplication {
pname = "my-app";
version = "0.1.0";
src = ./.;
goLock = ./go2nix.toml;
}
| gomod2nix | go2nix | Notes |
|---|---|---|
gomod2nix.toml, from gomod2nix generate | go2nix.toml, from go2nix generate . | Different files and formats; both hold one hash per module. go2nix’s keys are "path@version" and carry no package information. Delete the old file once nothing reads it. |
modules = ./gomod2nix.toml | goLock = ./go2nix.toml | Or goLock = null. |
pwd | not needed | src and modRoot say where go.mod is. |
buildGoApplication from an overlay | buildGoApplication from the scope mkGoEnv returns | Same name, different function. There is no overlay; the scope is the API. |
mkGoEnv { pwd = ./.; }, a development shell | no equivalent; go2nix.lib.mkGoEnv is something else | In go2nix mkGoEnv makes the toolchain scope the builders live in. For a shell use pkgs.mkShell with the same go. |
go, subPackages, ldflags, tags, CGO_ENABLED, doCheck | as in the buildGoModule table above |
gomod2nix fetches each module on its own, as go2nix does, but still compiles the application in one derivation. Everything under What changes applies here too, apart from the vendor-hash point.
Debugging a Build
A go2nix build is many small derivations, so the first step is always the same: find out which one failed, then look at that one alone. This page is about the default builder; Troubleshooting has the known error messages.
Which derivation failed
Nix names it:
error: builder for '/nix/store/…-gopkg-github.com-mattn-go-isatty-v0.0.20.drv' failed with exit code 1
| Name | What it was doing |
|---|---|
gomod-<module>-<version> | downloading one module (go mod download) |
gopkg-<import path>-<version> | compiling one third-party package |
golocal-<import path> | compiling one package of yours |
go-stdlib-… | compiling the standard library |
<pname>-deps-importcfg, <pname>-test-deps-importcfg | concatenating import maps; does not fail on its own |
<pname>-<version> | compiling the main packages, linking, running the tests |
In a name / became -, @ became _at_ and ~ became _, so
gopkg-golang.org-x-sys-unix-v0.25.0 is the package golang.org/x/sys/unix
of module version v0.25.0. nix log <that .drv> prints its build log, which
for a compile is the compiler’s own output.
If the failure is an evaluation error (error: resolveGoPackages: …,
attribute … missing), nothing was built yet: go to
Troubleshooting.
Build one package alone
The application derivation carries the whole graph in passthru, keyed by
import path:
# what is in the build
nix eval .#my-app.passthru.packages --apply builtins.attrNames
nix eval .#my-app.passthru.localPackages --apply builtins.attrNames
# build one, and only what it needs
nix build '.#my-app.passthru.packages."github.com/fatih/color"' --print-out-paths
nix build '.#my-app.passthru.localPackages."example.com/my-app/internal/db"'
# keep the build directory of a failing one
nix build '.#my-app.passthru.packages."github.com/mattn/go-sqlite3"' --keep-failed
passthru has packages (third-party), localPackages, testPackages
(third-party packages only tests import, with doCheck), depsImportcfg,
testDepsImportcfg, mainSrc (the filtered source the final derivation
sees), modulePath, go, go2nix and goLock.
A package’s output is small and readable:
$ find result -type f
result/importcfg
result/github.com/fatih/color.a
$ cat result/importcfg
packagefile github.com/fatih/color=/nix/store/…-gopkg-github.com-fatih-color-v1.18.0/github.com/fatih/color.a
With contentAddressed = true a local package also has an iface output
holding the .x export data and the importcfg its dependents read.
What a package derivation was told
Everything a compile gets is in its derivation, as plain environment variables:
pkg='.#my-app.passthru.packages."github.com/fatih/color"'
nix derivation show "$pkg" \
| jq '.. | .env? | select(.) | {goPackagePath, goPackageSrcDir, goModulePath, goModuleVersion}'
nix derivation show "$pkg" \
| jq '.. | .compileManifestJSON? | select(.) | fromjson'
{
"version": 2,
"kind": "compile",
"files": { "goFiles": [ "color.go", "doc.go" ] },
"importcfgParts": [ "/nix/store/…-go-stdlib-1.26.5/importcfg", "…" ],
"tags": [],
"gcflags": [],
"pgoProfile": null
}
goPackageSrcDir is the directory that gets compiled: inside the module’s
fetch output for a third-party package, a filtered copy of the package’s own
directory for a local one. The manifest has the rest: files (exactly which
files go list selected for this platform and tag set, by kind: goFiles,
cgoFiles, sFiles, cFiles, cxxFiles, hFiles, sysoFiles, …, and
embedPatterns), importcfgParts (the import maps of its dependencies),
tags, gcflags, pgoProfile. If a file you expected is not in files, a
build constraint excluded it (above, color_windows.go on Linux), and the
question is about tags, GOOS/GOARCH or CGO_ENABLED, not about go2nix.
The application derivation has linkManifestJSON and, with doCheck,
testManifestJSON in the same way.
See what go sees
The CLI has inspection commands that apply Go’s own file and package selection to a directory, outside any build:
nix run github:numtide/go2nix -- list-files -tags=netgo ./internal/db
nix run github:numtide/go2nix -- list-packages .
See the CLI Reference.
More output
GO2NIX_DEBUG=1 switches the CLI to debug logging. Inside a build it has to be
in the derivation’s environment: env.GO2NIX_DEBUG = "1" on
buildGoApplication for the link and test steps,
packageOverrides.<import path>.env.GO2NIX_DEBUG = "1" for one package’s
compile. Either changes the derivation, so expect it to rebuild.
When the wrong thing rebuilds
Compare the two derivations instead of guessing:
nix derivation show '.#my-app.passthru.localPackages."example.com/my-app/internal/db"' > before.json
# make the change
nix derivation show '.#my-app.passthru.localPackages."example.com/my-app/internal/db"' > after.json
diff <(jq -S . before.json) <(jq -S . after.json)
An input that changed is either goPackageSrcDir (a file in the package’s
directory changed, tests and testdata/ included), an importcfgParts entry
(a dependency changed; follow it), or a flag in the manifest.
Incremental Builds lists what each kind of derivation
is keyed on, and nix-diff does
the same comparison recursively.
Troubleshooting
error: attribute 'resolveGoPackages' missing
The default-mode builder calls builtins.resolveGoPackages, which is
provided by the go2nix Nix plugin. This error means the evaluating Nix
hasn’t loaded it.
Load the plugin via nix.conf plugin-files = ... or
--option plugin-files <path>. See Nix Plugin. Just before
this error Nix also prints a go2nix-nix-plugin: API level mismatch warning
with nix builtin resolver = 0; that is the same problem seen from the
builder’s side. nix eval --expr 'builtins ? resolveGoPackages' tells you
whether the plugin is loaded.
If the plugin is configured and you still see this, your Nix and the
plugin were built against different libnixexpr versions — rebuild the
plugin against the Nix you’re evaluating with.
go2nix-nix-plugin: API level mismatch
evaluation warning: go2nix-nix-plugin: API level mismatch.
nix builtin resolver = 0
nix/dag/default.nix = 1
Your nix was built against a different go2nix-nix-plugin revision
than the nix/ tree you are evaluating. Rebuild/reload the plugin
against this checkout.
The builder and the plugin each carry a number that is bumped when the data
passed between them changes shape. resolver = 0 means no plugin answered at
all (the next thing you see is the resolveGoPackages missing error above).
Any other pair means the plugin and the nix/ tree come from different
go2nix revisions: take go2nix-nix-plugin, the go2nix CLI and lib.mkGoEnv
from the same flake input, and rebuild whatever plugin-files points at after
updating that input. It is a warning, so evaluation goes on, and what happens
next depends on what changed between the two revisions.
resolveGoPackages: 'go list' failed (exit 1).
The plugin ran go list on your module and go itself gave up. Its own
message follows on the next lines, and that is the one to read. The trailing
Hint: ensure all modules are in your local cache only applies to the first
case below.
go: downloading …followed by a network error, a 404 or a 401: a module is not inGOMODCACHEand could not be downloaded where Nix evaluates. The evaluation-timego listinheritsGOMODCACHE,GOPATH,HOME,GOPROXY,NETRCand the TLS certificate variables, and nothing else: noGOPRIVATE, noGONOSUMDB, noHTTPS_PROXY, andgo env -wsettings do not apply (GOENV=off).goProxyon the build overridesGOPROXY. Rungo mod downloadin the module, or fix the proxy and credentials (see Private modules).go: download go1.N for linux/amd64: toolchain not available(or a toolchain being downloaded at all): thegoline ofgo.modasks for a newer Go than the one in your scope. Lower the directive or use a newergoinmkGoEnv;nix eval --raw nixpkgs#go.versiontells you what you have.go: errors parsing go.mod: exactly that.
resolveGoPackages: package errors:
error: resolveGoPackages: package errors:
- example.com/my-app/internal/nope: cannot find module providing package example.com/my-app/internal/nope: import lookup disabled by -mod=readonly
Hint: your GOMODCACHE may be stale. Run 'go mod download' to populate it.
go list ran, but some packages came back with errors; each line is one
package and what go said about it.
resolveGoPackages: test dependency errors: is the same thing from the
second pass, which looks at test imports when doCheck is on. The hint at the end is rarely the cause. In order of
likelihood:
- A local package that Nix cannot see. The import path is inside your own
module and the directory exists on disk, but not in
src: a new directory that is notgit add-ed yet (flakes only copy tracked files), or one thatsrcFilteror alib.filesetleaves out. go.modorgo.sumis not tidy.missing go.sum entry for module providing package …, orcannot find module providing package …for a third-party import: rungo mod tidy. The plugin runs with-mod=readonly, so it never fixes them for you.- A build constraint excludes every file of a package for the target
platform or tag set (
build constraints exclude all Go files in …): checktags,goEnv.GOOS/GOARCHandCGO_ENABLED.
warning: resolveGoPackages: realising derivation '…' at eval time (IFD)
src is the output of a derivation, typically pkgs.fetchFromGitHub. The plugin has to read the source while Nix evaluates,
so Nix must build that derivation first: import from derivation. It works if
allow-import-from-derivation is on, but it serialises evaluation behind a
build. Use a path, a flake input, builtins.fetchGit or builtins.fetchTarball
instead; see Builder API.
packageOverrides.<path>: unknown attributes ["nativeBuildInputs"]
Full message:
packageOverrides.<path>: unknown attributes ["nativeBuildInputs"]. Valid: env, srcOverlay (nativeBuildInputs is cgo-only — rawGoCompile hardcodes PATH)
You set nativeBuildInputs for a package that go2nix classified as
non-cgo. Non-cgo packages use a raw builder that bypasses stdenv, so
nativeBuildInputs would have no effect; the builder rejects it instead of
silently ignoring it. (It does so only when the key is that package’s exact
import path. Under a module-path key the attribute is simply not applied to
the module’s non-cgo packages.)
Fixes:
- If the package really is cgo, make sure
CGO_ENABLEDisn’t forced to0and that the cgo files aren’t excluded by build tags on your target platform. - If you need to influence a pure-Go compile, use
envinstead. - If you only need the inputs at link time, put them in the top-level
nativeBuildInputsofbuildGoApplicationrather than inpackageOverrides.
See Package Overrides.
Stale lockfile: attribute '"<module>@<version>"' missing, or link-binary fails validating go.mod
A lockfile that no longer matches go.mod shows up in one of two ways.
If a package in the build imports a module (or a version) the lockfile does not have, evaluation stops before anything is built:
error: attribute '"gopkg.in/yaml.v3@v3.0.1"' missing
Otherwise default mode still validates the lockfile against go.mod at link
time (mvscheck), which catches requirements the package graph did not
reach:
link-binary failed ... lockfile check: go.mod requires modules not found in lockfile ...
the lockfile is stale; run `go mod tidy && go2nix generate` to update it
Either way, regenerate — generate reads go.mod, so tidy it first:
go mod tidy && go2nix generate .
You do not need to regenerate after editing imports between packages that already exist — the lockfile pins modules, not the package graph. See When to regenerate.
Run go2nix check . to validate without building. In experimental mode the
same condition reads
lockfile missing module <module>@<version> — regenerate with go2nix generate.
parsing go2nix.toml: unknown keys: […]
The lockfile has sections this version of go2nix does not know, which is what
an old-format lockfile looks like ([pkg], for instance). Every Go-side
reader rejects it, go2nix generate included, because it reads the existing
file first to reuse its hashes. Delete the file, or write to a new path with
-o, and generate again.
Private modules: 404 or auth failures in module FODs
Module fetch derivations run go mod download in a sandbox. In default mode
they inherit GOPROXY and NETRC from the builder’s environment, which a
Nix daemon or a remote builder usually does not have; to make a build
independent of that, set the proxy with buildGoApplication { goProxy = ...; }
and pass a .netrc via mkGoEnv { netrcFile = ...; }. go2nix generate
downloads the same modules from your shell and needs the credentials there
too. See
Private modules for the format
and the store-path-visibility caveat.
compile manifest: unsupported version N (expected M)
Also link manifest: …, test manifest: …, and
manifest is missing files; rebuild the nix-plugin. The Nix side writes a
small JSON manifest for each compile, link and test run, and the go2nix CLI
inside the derivation refuses one written for a different format. It means
the CLI (mkGoEnv { go2nix = …; }), the nix/ tree (go2nix.lib) and the
plugin are not from the same go2nix revision. Take all three from one flake
input. The missing files form specifically means the plugin is older than
the builder: rebuild and reload it.
Experimental mode
go2nix dynamic mode requires the dynamic-derivations experimental feature (which implies ca-derivations). Enable with: extra-experimental-features = dynamic-derivations ca-derivations recursive-nix
The evaluating Nix has no builtins.outputOf. Enable the features for the
evaluator, not only for the daemon.
go2nix dynamic mode requires Nix >= 2.34 (v4 derivation JSON format), got <version>
The check is against the nixPackage given to mkGoEnv, the Nix that runs
inside the wrapper derivation, not against the Nix you are typing commands
into.
buildGoApplicationExperimental requires nixPackage to be set in mk-go-env
Pass nixPackage = pkgs.nixVersions.nix_2_34 (or newer) to mkGoEnv.
packageOverrides.<path>: unknown attributes ["env"]. Experimental mode only supports: nativeBuildInputs
The per-package derivations are made at build time by go2nix resolve, which
can add inputs to them but not environment variables or source overlays. Use
the default builder if you need env or srcOverlay.
lockfile missing module <module>@<version> — regenerate with go2nix generate
In the wrapper’s build log: the experimental builder has no lockfile-free mode and found a module the lockfile does not list. See the stale-lockfile entry above.
Evaluation feels slow on large graphs
Every evaluation runs go list -json -deps (via the plugin) and
instantiates one derivation per package. On a few-thousand-package graph
this is a few hundred milliseconds of floor on every nix build, even when
nothing changed. That’s expected; see
Incremental Builds.
If builds (not eval) cascade further than you expect after small edits,
turn on contentAddressed = true so private-symbol changes don’t rebuild
reverse dependents. Use bench-incremental to measure.
Inspecting the package graph
Debugging a Build is the longer version of this section.
The default-mode app derivation exposes the graph through passthru:
# All third-party package derivations
nix eval .#my-app.passthru.packages --apply builtins.attrNames
# All local package derivations
nix eval .#my-app.passthru.localPackages --apply builtins.attrNames
# Build a single package in isolation
nix build '.#my-app.passthru.packages."github.com/foo/bar"'
# The bundled importcfg used at link time
nix build .#my-app.passthru.depsImportcfg
Also available: passthru.go, passthru.go2nix, passthru.goLock,
passthru.mainSrc, passthru.modulePath, and (when doCheck = true)
passthru.testPackages / passthru.testDepsImportcfg.
Builder API Reference
Both builders accept a shared set of attributes. Differences are noted below.
buildGoApplication (default mode)
goEnv.buildGoApplication {
src = ./.;
goLock = ./go2nix.toml;
pname = "my-app";
version = "0.1.0";
}
buildGoApplicationExperimental (experimental mode)
goEnv.buildGoApplicationExperimental {
src = ./.;
goLock = ./go2nix.toml;
pname = "my-app";
}
Requires nixPackage to be set in mkGoEnv and Nix >= 2.34 with
recursive-nix, ca-derivations, and dynamic-derivations enabled.
The result is a wrapper derivation whose output is a .drv file; the binary
is its .target attribute (see Experimental Mode).
Attributes marked “default only” below are not rejected by this builder, they
are silently ignored.
Required attributes
src
Source tree. For monorepos with modRoot, this should be the repository
root.
In default mode, src is passed to builtins.resolveGoPackages, which
shells out to go list at eval time. To keep that call IFD-free, prefer a
source path (./.) or an eval-time fetcher (builtins.fetchTarball,
builtins.fetchGit). A derivation-backed value such as
pkgs.fetchFromGitHub is accepted, but the plugin must realise it at eval
time and emits an IFD warning.
| Attribute | Type | Modes | Description |
|---|---|---|---|
src | path | both | See above. |
pname | string | both | Package name for the output derivation. |
version | string | default only | Package version. The experimental builder ignores it (its wrapper produces a CA .drv whose name is derived from pname alone). |
Optional attributes
| Attribute | Type | Default | Modes | Description |
|---|---|---|---|---|
goLock | path or null | null (default) / required (experimental) | both | Path to go2nix.toml. In default mode, null enables lockfile-free builds. The experimental builder requires a lockfile. |
subPackages | list of strings | [ "." ] | both | Packages to build, relative to modRoot. A ./ prefix is auto-added if missing. |
modRoot | string | "." | both | Subdirectory within src containing go.mod. |
tags | list of strings | [] | both | Go build tags. |
ldflags | list of strings | [] | both | Flags passed to go tool link (-s, -w, -X, etc.). |
gcflags | list of strings | [] | both | Extra flags passed to go tool compile. |
CGO_ENABLED | 0, 1, or null | the scope’s goEnv.CGO_ENABLED if set, else null (default mode); null (experimental) | both | Value of CGO_ENABLED for the eval-time go list and the build. With null the toolchain’s default applies, and a package is built as cgo when go list reports CgoFiles for it, i.e. it has Go files that import "C". |
pgoProfile | path or null | null | both | Path to a pprof CPU profile for profile-guided optimization. The profile is passed to every go tool compile invocation, so changing it invalidates all package derivations. See Go’s PGO docs for producing a profile. |
nativeBuildInputs | list | [] | both | Extra build inputs for the final derivation. In experimental mode they land on the wrapper that runs go2nix resolve, not on the link derivation; give link-time libraries through packageOverrides. |
packageOverrides | attrset | {} | both | Per-package customization (see below). |
doCheck | bool | true | default only | Run tests. Matches buildGoModule’s default. See Test Support. |
checkFlags | list of strings | [] | default only | Flags passed verbatim to the compiled test binary, so in its own spelling: -test.v, -test.run=^TestFoo$, -test.count=1 (not go test’s -v, -run). See Test Support. |
extraMainSrcFiles | list of strings | [] | default only | Extra src-relative paths (files or directories) kept in the filtered test source tree. Escape hatch for tests that read runtime files outside testdata/ and //go:embed. See Test Support. |
srcFilter | function path: type: bool | keep everything | default only | Extra predicate, with builtins.path’s filter signature, ANDed into every filtered copy the builder makes of src (each local package’s source and the test source tree). Pass the real source tree as src plus the membership a pre-filtered copy would have had (e.g. a lib.fileset’s files and their directories) instead of a lib.fileset.toSource / builtins.path copy: the builder walks and go lists src during evaluation, which over a store copy only works once that copy has been written, so a pre-copied src cannot be evaluated read-only against a store that has not seen it. Per-package store paths are unchanged as long as the predicate admits the same files. |
goProxy | string or null | null | default only | GOPROXY for the module fetches and for the eval-time go list, written into the derivations. With null the fetches inherit GOPROXY/NETRC from the builder’s environment (a daemon or a remote builder usually has none, and Go’s default proxy applies). |
allowGoReference | bool | false | default only | Allow the output to reference the Go toolchain. The output may never reference the filtered source tree (mainSrc); both are enforced with disallowedReferences. |
meta | attrset | {} | default only | Nix meta attributes. |
contentAddressed | bool | false | default only | Make the local-package derivations floating-CA with an extra iface (export-data) output, so private-symbol-only edits don’t cascade, and the importcfg bundle floating-CA too. Third-party packages stay input-addressed. Requires the ca-derivations experimental feature; the final binary stays input-addressed. See Incremental Builds → Early cutoff for details and limitations. |
Anything else you pass to buildGoApplication goes to stdenv.mkDerivation
unchanged (preBuild, postInstall, outputs, …). buildInputs,
disallowedReferences, env and passthru are merged with the builder’s
own. The result’s passthru has go, go2nix, goLock, packages,
localPackages, depsImportcfg, mainSrc, modulePath and, with doCheck,
testPackages and testDepsImportcfg; see
Troubleshooting for how to use them. extraMainSrcFiles
entries that do not exist under src are an error.
What a call produces
One buildGoApplication call evaluates to one derivation, the application,
whose inputs are all the others:
| Derivation name | How many |
|---|---|
go-stdlib-<go version>[-<hash of goEnv>] | one per scope, shared by every build in it (see The Scope) |
gomod-<module path>-<version> | one per module the build uses |
gopkg-<import path>-<module version> | one per third-party package the build reaches |
golocal-<import path> | one per local package, and golocal-<import path>-src for its filtered source |
<pname>-deps-importcfg, and <pname>-test-deps-importcfg with doCheck | one each |
<pname>-<version> | the application: compiles the main packages, links, runs the tests |
Characters that cannot appear in a store path are replaced (/ by -, @
by _at_, ~ by _). result/bin/ holds one binary per entry of
subPackages: the package at the module root is named pname, any other
one after its directory (./cmd/server gives bin/server).
modRoot
When building one module inside a larger source tree (e.g., a monorepo), set
src to the repository root and modRoot to the subdirectory containing
go.mod:
goEnv.buildGoApplication {
src = ./.;
goLock = ./app/go2nix.toml;
pname = "my-app";
version = "0.1.0";
modRoot = "app";
subPackages = [ "cmd/server" ];
}
This is necessary when the module uses replace directives pointing to sibling
directories outside modRoot. The builder needs access to the full src tree,
with modRoot telling it where go.mod lives (a leading ./ is accepted). The filtered mainSrc for the
final derivation unions in those sibling replace directories, so doCheck
works regardless of modRoot.
subPackages
List of packages to build, relative to modRoot. Each entry is a Go package
path like "cmd/server" or "." (the module root package).
A ./ prefix is added automatically if missing, so "cmd/server" and
"./cmd/server" are equivalent.
The default [ "." ] builds the package at modRoot.
packageOverrides
Per-package customization keyed by Go import path or module path:
packageOverrides = {
"github.com/mattn/go-sqlite3" = {
nativeBuildInputs = [ pkg-config sqlite ];
};
};
See Package Overrides for the lookup rules, supported keys, cgo recipes, and mode differences.
mkGoEnv
Both builders are accessed through a scope created by mkGoEnv:
goEnv = go2nix.lib.mkGoEnv {
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix;
# Optional:
goEnv = { CGO_ENABLED = "0"; };
netrcFile = ./my-netrc;
nixPackage = pkgs.nixVersions.nix_2_34; # required for experimental mode
};
| Attribute | Type | Default | Description |
|---|---|---|---|
go | derivation | required | Go toolchain. |
go2nix | derivation | required | go2nix CLI binary. |
callPackage | function | required | pkgs.callPackage. |
tags | list of strings | [] | Stored on the scope, but neither builder reads it: pass tags to each buildGoApplication call. |
goEnv | attrset | {} | Environment variables applied to stdlib compilation and, in default mode, to every compile and link in this scope (e.g. GOEXPERIMENT, GOFIPS140, CGO_ENABLED). In experimental mode they reach the stdlib and go2nix resolve only, not the per-package derivations. Of these only CGO_ENABLED, GOOS and GOARCH also reach the eval-time go list. Scope-level because the stdlib derivation is shared by every build in the scope. |
netrcFile | path or null | null | .netrc file for private module authentication (see below). |
nixPackage | derivation or null | null | Nix binary. Required for buildGoApplicationExperimental. |
Cross-compilation
GOOS / GOARCH are read from stdenv.hostPlatform.go, so cross builds are
driven the standard nixpkgs way — pass a cross pkgs (e.g.
pkgsCross.aarch64-multiplatform) into mkGoEnv via callPackage, and the
resulting scope produces binaries for that target. goEnv.GOOS / goEnv.GOARCH
win over the platform’s if you set them. Only callPackage has to come from
the cross package set: go runs on the build machine, so keep passing a
native one. The Nix plugin is told the target goos/goarch
so build-tag evaluation matches the host platform. Default mode only.
FIPS 140 mode (GOFIPS140)
Set GOFIPS140 via the scope-level goEnv to build against the Go FIPS 140
crypto module, equivalent to GOFIPS140=latest go build (default mode; the
experimental builder does not pass goEnv to its compile and link steps):
goEnv = go2nix.lib.mkGoEnv {
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix;
goEnv = { GOFIPS140 = "latest"; };
};
The variable reaches both go install std (so the FIPS-aware stdlib is
compiled) and the link step, where go2nix emits the matching
build GOFIPS140= modinfo line and folds fips140=on into
DefaultGODEBUG — go version -m output is identical to a vanilla
GOFIPS140=latest go build -trimpath.
Private modules (netrcFile)
Go modules hosted behind authentication (private Git repos, private proxies)
require credentials. Set netrcFile in mkGoEnv to a .netrc file:
goEnv = go2nix.lib.mkGoEnv {
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix;
netrcFile = ./secrets/netrc;
};
The file uses standard .netrc format:
machine github.com
login x-access-token
password ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
machine proxy.example.com
login myuser
password mytoken
The file is copied to $HOME/.netrc inside each module fetch derivation, which
covers a proxy that wants authentication (point goProxy at it). Go’s
direct fallback shells out to git or another VCS tool, and the fetch
derivations have none on PATH, so do not count on it for private
repositories: serve them through a proxy.
In experimental mode, the file is passed as --netrc-file to
go2nix resolve, which forwards it to the module FODs built inside
the recursive-nix sandbox.
Note: Any value passed to netrcFile reaches a fixed-output derivation
and is therefore world-readable in /nix/store. There is currently no
mechanism to keep the credential out of the store entirely; use a
low-privilege, repository-scoped token (and rotate it) rather than a
personal credential.
The Scope
go2nix.lib.mkGoEnv returns a scope: a package set made with
lib.makeScope in which everything shares one Go toolchain, one go2nix CLI
and one compiled standard library. Most projects only ever call
buildGoApplication on it. The rest is what the builders are made of, and it
is there to be read, reused and overridden. The arguments of mkGoEnv itself
are in the Builder API.
Members
| Member | What it is |
|---|---|
buildGoApplication | The default builder. See Builder API. |
buildGoApplicationExperimental | The experimental builder; throws unless mkGoEnv was given nixPackage. See Experimental Mode. |
go, go2nix, nixPackage, netrcFile | What mkGoEnv was given. |
goEnv | What mkGoEnv was given, with GOOS and GOARCH filled in from the host platform when cross-compiling. |
stdlib | The compiled standard library; below. |
fetchers.fetchGoModule | The function that makes a module’s fetch derivation; below. |
hooks | Setup hooks used by the derivations that need stdenv; below. |
helpers | Pure functions shared by the builders; below. |
tags, tagFlag | The tags given to mkGoEnv and their comma-joined form. Neither builder reads them; pass tags to each build. |
hasDynamicDerivations | builtins ? outputOf: whether this Nix can evaluate the experimental builder’s result. |
callPackage, newScope, overrideScope, packages, lib | From lib.makeScope and nixpkgs. |
Overriding
overrideScope takes an overlay and returns a new scope in which every
member, the builders included, sees the change:
goEnv' = goEnv.overrideScope (final: prev: {
fetchers = prev.fetchers // {
fetchGoModule = args:
(prev.fetchers.fetchGoModule args).overrideAttrs (old: {
impureEnvVars = old.impureEnvVars ++ [ "GOPRIVATE" ];
});
};
});
goEnv'.buildGoApplication now fetches every module through the wrapped
function. Because a fetch is a fixed-output derivation, changing how it
fetches does not change its output path.
Two scopes with a different go or goEnv have different standard
libraries, and therefore share no compiled package; two applications built
from the same scope share every third-party package they have in common.
stdlib
One derivation per scope, named go-stdlib-<go version>, plus -<8 hex>
(a hash of goEnv) when goEnv is not empty. It copies GOROOT and runs
go install --trimpath std with goEnv exported, so settings that change
which standard-library sources get compiled (GOFIPS140, GOEXPERIMENT,
CGO_ENABLED, GOOS/GOARCH) belong in goEnv. Output:
$out/<import path>.afor every standard-library package$out/importcfg, onepackagefile <import path>=<archive>line each
Every compile reads that importcfg; this is the only place go2nix runs
cmd/go. Packages and the final link use go tool compile and
go tool link directly.
fetchers.fetchGoModule
goEnv.fetchers.fetchGoModule {
fetchPath = "github.com/fatih/color"; # where to download from
version = "v1.18.0";
hash = "sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY=";
goProxy = null; # optional
}
Returns a fixed-output derivation named gomod-<fetchPath>-<version> (with
/ turned into -) that runs go mod download <fetchPath>@<version> and
keeps the module’s extracted source tree as $out: what go would put in
$GOMODCACHE/<path>@<version>/, with the download metadata left out. That
makes the hash the same whichever proxy served the module, and the same one
go2nix generate and the lockfile-free resolver compute, so all three agree
on one derivation per module version.
For a replaced module, fetchPath and version are the replacement’s; the
builder takes care of that.
How the fetch reaches the network:
GOPROXYandNETRCare inherited from the environment of whatever runs the build (impureEnvVars), along with the usual proxy variables.goProxyexportsGOPROXYinside the derivation instead, which is what you want under a daemon or a remote builder, where that environment is not yours.- The scope’s
netrcFileis copied to$HOME/.netrc. GOSUMDB=off: the fixed output hash is the integrity check.- Only
goand CA certificates are onPATH. There is nogit, so a module thatdirectwould fetch from version control cannot be fetched that way; it has to come from a proxy.
hooks
Pure-Go packages are compiled by a bare builtins.derivation that calls
go2nix compile-package directly. The hooks are for the derivations that
need stdenv: cgo packages (for the C compiler) and the final application.
| Hook | Used by | Reads |
|---|---|---|
setupGoEnv | both hooks below | sets HOME, GOPROXY=off, GOSUMDB=off, GONOSUMCHECK=* before configure |
goModuleHook | cgo package derivations | goPackagePath, goPackageSrcDir, goLangVersion, goModulePath, goModuleVersion, compileManifestJSON, optionally goSrcOverlay; writes the iface output when the derivation has one |
goAppHook | the application derivation | linkManifestJSON, and testManifestJSON when doCheck is on; installs $out/bin/* |
helpers
| Function | Contract |
|---|---|
sanitizeName s | s made safe for a derivation name: / becomes -, ~ becomes _, @ becomes _at_; past 160 characters the tail is replaced by - and 8 hex digits of sha256 s. The Go CLI and the plugin implement the same rule, because all three must produce the same names. |
escapeModPath s | Go’s module path escaping: every upper-case letter becomes ! and its lower-case form, the layout of GOMODCACHE. |
normalizeSubPackages list | Prefixes ./ to every entry that is not . and does not already start with ./, so that cmd/foo is never taken for a standard-library import path. |
parseLocalReplaces text | The filesystem targets (./…, ../…) of the replace directives in the text of a go.mod. |
goModLocalReplaceDirs dir | dir plus every directory reachable from its go.mod through filesystem replace directives, transitively. |
removePrefix prefix s | s without its first stringLength prefix characters. |
goModLocalReplaceDirs is the useful one outside the builders: it answers
“which directories does this module need?” for a module that lives in a
larger repository, so src can be just those:
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions (goEnv.helpers.goModLocalReplaceDirs ./services/api);
};
modRoot = "services/api";
Nix Plugin (resolveGoPackages)
Default mode needs to know the Go package graph at eval time so it can
turn each package into a separate derivation. Nix has no builtin that can
run go list, so go2nix ships a Nix plugin that adds one:
builtins.resolveGoPackages.
If you only use experimental mode (buildGoApplicationExperimental), you
do not need the plugin — that mode discovers the graph at build time inside
a recursive-nix sandbox.
What it provides
The plugin registers builtins.resolveGoPackages (plus a small probe,
builtins.go2nixApiLevel, see API level):
builtins.resolveGoPackages {
src = ./.; # source tree
subPackages = [ "." ]; # optional, default [ "." ]
modRoot = "."; # optional
tags = [ ]; # optional build tags
goos = "linux"; # optional cross target; a string, omit for the host's
goarch = "arm64"; # optional cross target; a string, omit for the host's
goProxy = null; # optional GOPROXY override; null inherits the environment
cgoEnabled = "0"; # optional CGO_ENABLED; a string, omit for Go's default
doCheck = false; # also resolve what the tests need
resolveHashes = false; # also compute module NAR hashes
}
goos, goarch and cgoEnabled are strings: leave them out rather than
passing null.
go is intentionally omitted: the plugin defaults to the Go toolchain baked
in at its own build time, so the call carries no derivation context and the
default-mode evaluation stays IFD-free. You may pass
go = "/path/to/bin/go" to override the toolchain (e.g. a nix-shell Go).
A derivation-backed value like "${pkgs.go}/bin/go" is accepted but emits a
warning and forces the plugin to realise the derivation at eval time — i.e.
opt-in IFD, still gated by allow-import-from-derivation. The same goes
for a derivation-backed src. Note that the baked-in toolchain is the
plugin’s own pkgs.go, which need not be the go you hand to mkGoEnv
for compiling.
It runs go list -deps -json for subPackages in src/modRoot and, when
doCheck is set and the build has local packages, a second
go list -deps -test pass over ./... and every filesystem-replaced
sibling module. It returns:
packages— third-party packages by import path:modKey(path@version, the replacement’s version when there is one),subdir,imports(third-party import paths only),drvName,files(the file listsgo listreported, by kind), and when they applyisCgo,cgoPkgConfig,cgoCflags,cgoLdflags, andlocalImports(imports that resolve to local packages, which happens when the maingo.modreplaces a module the package imports with a directory)localPackages— packages of the main module and of modules it replaces with a directory:dir(relative tosrc,"."for the root),modPath(the owning module),localImports,thirdPartyImports,files,mainSrcFiles(what the final derivation’s source must keep for this package), and the same cgo fieldstestPackages,testLocalPackages— the same two shapes for what only the tests reach;testPackagesis{ }withoutdoCheck, andtestLocalPackagesis left out when emptymodulePath— the main module’s import pathgoVersion— the main module’sgodirective (e.g."1.25"); the dag builder threads this as-langto local-package compilesreplacements— for modules replaced by another module (replace a => b v1.2.3) that own a package in the graph:modKey → { path, version }. Filesystem replaces are not here; they aresiblingModulessiblingModules— modules replaced with a directory, by module path:path,version(from therequireline),goVersion,replaceDir; left out when there are nonelocalReplaceDirs,nestedModuleRoots— the replace target directories (followed transitively) and every directory under them andmodRootthat holds ago.mod, both relative tosrcsubPackageClosures— per main package:modKeysandsiblingModPaths(the modules it links, for the embedded module info) andcxx(whether it needs a C++ linker)moduleHashes— module NAR hashes (whenresolveHashes = true; see Lockfile-free builds)apiLevel— see below
You normally never call this directly — buildGoApplication does.
API level
The shape above is a contract between the plugin and nix/dag. Both carry
a number (API_LEVEL in the plugin’s Rust core, apiLevel in
nix/dag/default.nix), the plugin exposes its own as
builtins.go2nixApiLevel, and the builder compares the two before calling
the resolver. On a mismatch it prints a warning (“go2nix-nix-plugin: API
level mismatch”) and carries on, so the next error you see is usually a
missing attribute: rebuild or reload the plugin from the same revision as
the nix/ tree you evaluate. An incompatible change to the output bumps
the number on both sides; an additive, optional field does not.
Architecture
The plugin lives under packages/go2nix-nix-plugin/ and is built in two
halves:
- a Rust core (
rust/) that wrapsgo list, parses its JSON output, classifies packages and computes module hashes; - a C++ shim (
plugin/resolveGoPackages.cc) that registers the primop with the Nix evaluator and marshals the Rust output back into Nix values.
The shim uses Nix’s C++ API, which is unstable across releases, so the
plugin must be built against the same Nix version you evaluate with.
The package builds against nixVersions.nix_2_34; to target another Nix,
change the nixComponents binding in
packages/go2nix-nix-plugin/default.nix. The build requires Nix 2.34 or
newer, which makes that the minimum for default mode as a whole.
Loading the plugin
Build it from this flake:
nix build github:numtide/go2nix#go2nix-nix-plugin
Then make the evaluator load it. Either set it globally in nix.conf:
plugin-files = /nix/store/.../lib/nix/plugins/libgo2nix_plugin.so
or pass it per-invocation:
nix build --option plugin-files /nix/store/.../lib/nix/plugins/libgo2nix_plugin.so .#my-app
The latter is what the bench-incremental harness does internally.
Loading the plugin from a flake
Rather than hand-pasting a store path, derive it from the flake input.
On NixOS:
{ inputs, pkgs, ... }: {
nix.settings.plugin-files = [
"${inputs.go2nix.packages.${pkgs.system}.go2nix-nix-plugin}/lib/nix/plugins/libgo2nix_plugin.so"
];
}
Ad-hoc on the command line:
nix build .#my-app \
--option plugin-files \
"$(nix build --no-link --print-out-paths github:numtide/go2nix#go2nix-nix-plugin)/lib/nix/plugins/libgo2nix_plugin.so"
If the plugin is not loaded, evaluating buildGoApplication first warns
about an API level mismatch (the resolver’s level reads as 0) and then fails
with:
error: attribute 'resolveGoPackages' missing
To check from the command line: nix eval --expr 'builtins ? resolveGoPackages'.
Purity
builtins.resolveGoPackages is impure: it runs go list. The Nix
evaluator does not cache its result, so it runs on every evaluation,
once per buildGoApplication call — twice with doCheck, which is the
builder’s default.
What go list sees is fixed by the plugin, not by your shell. The
environment is cleared and only GOMODCACHE, GOPATH, HOME, GOPROXY
and NETRC are passed through (goProxy overrides GOPROXY), plus PATH,
TMPDIR and the certificate variables unless goProxy = "off". It then
sets GOFLAGS=-mod=readonly, GOENV=off, GOWORK=off and
GONOSUMCHECK=*, and GOOS/GOARCH/CGO_ENABLED from the arguments.
Everything else — GOPRIVATE, GONOPROXY, GOTOOLCHAIN, GOEXPERIMENT,
GOFIPS140, your own GOFLAGS, the scope’s goEnv — does not reach it.
Modules are read from GOMODCACHE; one that is missing is downloaded
through GOPROXY if the network allows, otherwise go list fails and the
error asks you to run go mod download.
With resolveHashes = true (what goLock = null turns on) the plugin also
hashes each module’s tree in GOMODCACHE and remembers the result on disk,
keyed by the module’s h1: line in go.sum, under
$XDG_CACHE_HOME/go2nix/nar/ (falling back to ~/.cache, then /tmp).
Only modules the build uses are hashed, and one whose tree is not in
GOMODCACHE is skipped; the build then fails when it looks that module’s
hash up.
This is the dominant per-eval cost of default mode; see Incremental Builds for timings.
CLI Reference
All commands are subcommands of go2nix. Set GO2NIX_DEBUG=1 for verbose
output.
Commands you run
generate
Generate a lockfile from one or more Go module directories.
go2nix generate [flags] [dir...]
| Flag | Default | Description |
|---|---|---|
-o | go2nix.toml | Output lockfile path |
-j | NumCPU | Max parallel hash invocations |
When no directory is given, defaults to .. Multiple directories produce a
merged lockfile (monorepo support).
The generated lockfile is shared by both builder modes. Use
buildGoApplication (default) or buildGoApplicationExperimental in Nix.
Examples:
go2nix generate . # write go2nix.toml in the current module
go2nix # same — a completely bare invocation runs generate
go2nix generate -o lock.toml ./a ./b # merged lockfile for two modules
generate reads each directory’s go.mod (its require and replace
lines, so tidy it first) and downloads every module to hash it: it needs
go on PATH and access to your GOPROXY. An existing output file is used
as a cache, so a re-run only downloads what changed. -o is relative to the
current directory, not to dir. Only the bare go2nix defaults to
generate; go2nix ./dir or go2nix -o x.toml is an unknown command, and
there is no top-level --help (each subcommand has -h).
See Lockfile Format for the output schema.
check
Validate a lockfile against go.mod.
go2nix check [flags] [dir]
| Flag | Default | Description |
|---|---|---|
--lockfile | go2nix.toml | Path to lockfile for consistency check |
Verifies that every go.mod requirement (filesystem replaces aside) has a
path@version entry in the lockfile’s [mod]. It does not recompute hashes,
look at [replace], or complain about entries that are no longer needed.
It prints nothing and exits 0 on success; on failure it exits 1 with
check failed and the missing modules. Notes: flags go before dir
(go2nix check --lockfile x.toml dir), only one directory is taken, the
default --lockfile is relative to the current directory, and a lockfile
that does not exist reads as an empty one, so every module is reported
missing.
Commands the builders run
compile-package, link-binary and test-packages are what the default
mode’s derivations run; resolve is what the experimental mode’s wrapper
runs (and it calls compile-package from the derivations it registers). You
won’t normally run these; they are documented for debugging build failures.
The manifests they read are JSON files the Nix side writes into the build
directory.
compile-package
Compile a single Go package to an archive (.a file). Every per-package
derivation runs it, in both modes: directly from the raw builder for pure-Go
packages, through the compile-go-pkg.sh hook for cgo ones, and from the
derivations go2nix resolve registers in experimental mode.
go2nix compile-package --manifest FILE --import-path PATH --src-dir DIR --output FILE [flags]
| Flag | Required | Description |
|---|---|---|
--manifest | Yes | Path to compile-manifest.json |
--import-path | Yes | Go import path for the package |
--src-dir | Yes | Directory containing source files |
--output | Yes | Output .a archive path |
--iface-output | No | Write export-data-only interface (.x) to this path; --output then receives the link object via -linkobj |
--importcfg-output | No | Write importcfg entry for consumers to this path |
--trim-path | No | Path prefix to trim (default: $NIX_BUILD_TOP) |
--p | No | Override -p flag (default: import-path) |
--go-version | No | Go language version for -lang (default: read from go.mod) |
--module-path | No | Owning module’s path; with --module-version, source paths are rewritten to <module>@<version>/... as go build -trimpath does |
--module-version | No | Owning module’s version (from the require line); empty for main-module packages, which rewrite to the import path |
link-binary
Link Go application binaries. Reads a link manifest that declares all inputs (importcfg parts, local archives, ldflags, etc.), validates the lockfile, generates modinfo, compiles main packages, and invokes the linker. Used internally by the default mode’s build phase.
go2nix link-binary --manifest FILE --output DIR
| Flag | Required | Description |
|---|---|---|
--manifest | Yes | Path to link-manifest.json |
--output | Yes | Output directory (binaries written to <output>/bin/) |
test-packages
Compile and run the tests of the local packages that are part of the build. Used internally by the default mode’s check phase.
go2nix test-packages --manifest FILE
| Flag | Required | Description |
|---|---|---|
--manifest | Yes | Path to test-manifest.json |
Discovers local packages with _test.go files, keeps those whose archive is
in the manifest (the subPackages closure plus test-only helpers; the rest
are skipped), compiles internal and external test archives, generates test mains, links test binaries, and
runs them. See test-support.md for details on the
test pipeline.
resolve
Build-time command for experimental mode (the nix/dynamic/ builder).
Discovers the package graph, computes CA .drv paths in-process, registers
them with the nix-daemon (falling back to nix derivation add if no daemon
socket is reachable), and produces a .drv file as output. See
Experimental Mode.
go2nix resolve [flags]
| Flag | Required | Description |
|---|---|---|
--src | Yes | Store path to Go source |
--mod-root | No | Subdirectory within src containing go.mod |
--lockfile | Yes | Path to go2nix.toml lockfile |
--system | Yes | Nix system (e.g., x86_64-linux) |
--go | Yes | Path to go binary |
--nix | Yes | Path to nix binary |
--pname | Yes | Output binary name |
--output | Yes | $out path |
--stdlib | Yes | Path to pre-compiled Go stdlib |
--go2nix | No | Path to go2nix binary (defaults to self) |
--bash | No | Path to bash binary |
--coreutils | No | Path to a coreutils binary (e.g., coreutils/bin/mkdir) |
--sub-packages | No | Comma-separated sub-packages |
--tags | No | Comma-separated build tags |
--ldflags | No | Linker flags |
--cgo-enabled | No | Override CGO_ENABLED (0 or 1) |
--gcflags | No | Extra flags for go tool compile |
--pgo-profile | No | Store path to pprof CPU profile for PGO |
--overrides | No | JSON-encoded packageOverrides |
--cacert | No | Path to CA certificate bundle |
--netrc-file | No | Path to .netrc for private modules |
--nix-jobs | No | Max concurrent derivation registrations |
--daemon-socket | No | nix-daemon Unix socket; default $NIX_DAEMON_SOCKET_PATH. When reachable, derivations are registered over the socket instead of via nix CLI subprocesses |
This command is not intended for direct use — it is invoked by the experimental-mode Nix builder inside a recursive-nix build.
Inspection tools
Not called by anything. They show what the builders see: which files go
would pick in a directory, which packages a module has, what build
information a binary would embed, what a generated test main looks like.
list-files
List Go source files for a package directory, respecting build tags and constraints.
go2nix list-files [-tags=...] [-go-version=...] <package-dir>
Outputs JSON with categorized file lists (Go files, C files, assembly, etc.).
-go-version sets the target Go toolchain version (e.g. 1.25) used to
evaluate //go:build go1.N constraints; defaults to go env GOVERSION.
list-packages
List all local packages in a Go module with their import dependencies.
go2nix list-packages [-tags=...] [-go-version=...] <module-root>
Outputs JSON with each package’s import path and dependencies.
build-modinfo
Generate a modinfo linker directive for embedding debug/buildinfo
metadata into the final binary. This is a standalone utility; the default
mode’s link-binary command generates modinfo internally.
go2nix build-modinfo [flags] <module-root>
| Flag | Required | Description |
|---|---|---|
--lockfile | Yes | Path to go2nix.toml lockfile |
--go | No | Path to go binary (default: from PATH) |
--main-path | No | Import path of the main package (default: the module path) |
--main-dir | No | Directory of the main package, read for //go:debug directives (default: MODULE_ROOT) |
Outputs a modinfo directive for the linker’s importcfg (embedding
debug/buildinfo metadata), and optionally a godebug line with the
default GODEBUG value parsed from the module’s go.mod (used for
-X=runtime.godebugDefault=...).
generate-test-main
Generate a _testmain.go file that registers test, benchmark, fuzz, and
example functions. Standalone: the test runner generates its mains in-process
with the same code, nothing calls this subcommand.
go2nix generate-test-main [flags]
| Flag | Required | Description |
|---|---|---|
--import-path | Yes | Import path of the package under test |
--module-path | No | Module path of the main module |
--test-files | No | Comma-separated absolute paths to internal _test.go files |
--xtest-files | No | Comma-separated absolute paths to external _test.go files |
--output | No | Output file path (default: stdout) |
Lockfile Format
go2nix uses TOML lockfiles to pin module hashes. Both builder modes share
the same lockfile format, generated by go2nix generate.
Format
# go2nix lockfile v2. Generated by go2nix. Do not edit.
[mod]
"github.com/foo/bar@v1.2.3" = "sha256-abc..."
"golang.org/x/sys@v0.20.0" = "sha256-def..."
[replace]
"github.com/foo/bar@v1.2.3" = "github.com/fork/bar"
Versioning
The header comment carries a format version (go2nix lockfile vN). The
current version is v2. go2nix generate always writes the current
version; older lockfiles should be regenerated. Nothing reads the version
back, but the Go side rejects sections it does not know
(parsing go2nix.toml: unknown keys: [...]), and generate reads the
existing output file first to reuse its hashes — so delete an old-format lockfile, or write
to a new path with -o, before regenerating.
Sections
[mod] — Module hashes. Each key is a composite "path@version" string,
each value is a sha256-... SRI NAR hash of the module’s extracted source
tree, i.e. $GOMODCACHE/<path>@<version>/ after go mod download and
nothing else from the cache (the same tree the FOD fetcher produces).
[replace] — Module replacements (from go.mod replace directives).
Maps a composite key — the original module path with the replacement’s
version — to the replacement module path; the [mod] hash under that same
key is the hash of the replacement’s tree. Only remote replacements are
recorded; local replace directives (filesystem paths) are not included, and
the modules they replace are not fetched. This section is read by the
experimental builder and by go2nix build-modinfo; the default builder gets
its replacements from the plugin and only reads [mod].
Composite keys
Module keys use "path@version" format (e.g., "golang.org/x/sys@v0.20.0").
This keeps each module uniquely identified and avoids collisions across
versions.
Package graph resolution
The lockfile stores only module NAR hashes; the package graph is discovered
separately (eval-time plugin in default mode, build-time go list in
experimental mode — see Builder Modes).
Monorepo support
When go2nix generate is given multiple directories, all modules are merged
into a single lockfile. Modules from different go.mod files coexist without
conflict since each is uniquely keyed by "path@version".
When to regenerate
Regenerate the lockfile when — and only when — the module set changes:
- you add, remove, or bump a
requireline ingo.mod - a
replacedirective changes which remote module a path resolves to
generate and the staleness check look at go.mod only, so keep it tidy
(go mod tidy); a change to go.sum alone neither needs nor triggers
anything.
You do not need to regenerate after changing which packages import
which other packages, adding a new local package, or editing .go files.
The lockfile pins module hashes; the package graph is rediscovered on every
evaluation (see Package graph resolution).
go2nix generate . # rewrite go2nix.toml
go2nix check . # verify go2nix.toml still matches go.mod, no rewrite
Lockfile-free builds
Default mode can build without a lockfile by setting goLock = null:
goEnv.buildGoApplication {
src = ./.;
goLock = null;
pname = "my-app";
version = "0.1.0";
}
When no lockfile is present, the Nix plugin is invoked
with resolveHashes = true and computes a NAR hash for each module from
go.sum + GOMODCACHE, returning a moduleHashes attrset that fills the
role of the [mod] section. Module FODs are then keyed on those hashes.
This trades a checked-in pin file for zero lockfile maintenance. The build
is still reproducible as long as go.sum is unchanged, but you lose the
explicit, reviewable hash list.
Note: the build-time staleness check (
mvscheck, see below) is skipped whengoLock = null— there is no lockfile forgo.modto drift from. Module versions are read live fromgo.sumvia the plugin on every evaluation, so ago getis reflected on the nextnix buildwith nothing to regenerate. The standalonego2nix checksubcommand comparesgo.modwith a lockfile and is not applicable in this mode.
Prefer a committed lockfile for anything you ship; lockfile-free is useful for ad-hoc builds and during early development.
Staleness detection
| When | What | Applies to | How |
|---|---|---|---|
| Generation | The module set | All modes | generate takes every require line of go.mod, with replace applied, and hashes each module; it runs no go list and relies on go.mod being tidy |
| Nix eval | Package graph, and the modules it uses | Default only | builtins.resolveGoPackages runs go list at eval time; a module the graph uses and the lockfile lacks fails with attribute '"<module>@<version>"' missing |
| Build time | Lockfile consistency | Default, with lockfile | link-binary re-reads go.mod and checks every required module is present in the lockfile at the right version; skipped when goLock = null |
In default mode a module that a package in the build imports but the
lockfile does not have stops evaluation; requirements the package graph did
not reach (another platform’s, or a test’s when doCheck is off) are caught
at build time when link-binary validates the lockfile. There is no stale
package graph: builtins.resolveGoPackages runs go list on every
evaluation. In experimental mode go2nix resolve reports
lockfile missing module <module>@<version> — regenerate with go2nix generate
inside the recursive-nix sandbox, or go list or a module fetch fails there.
Run go2nix check <dir> or go2nix check --lockfile <path> <dir> to verify
a lockfile without building.
go2nix Architecture
Technical reference for the go2nix build system.
Overview
go2nix builds Go applications in Nix with two modes that share the same Go CLI and lockfile infrastructure but differ in how they create derivations.
The system has three components:
- A Go CLI (
go2nix) that generates and validates lockfiles and is what the derivations run: it compiles one package, links a binary, and builds and runs the tests. - A Nix library (
nix/) that turns the package graph into derivations, in one of two modes. - A Nix plugin (
packages/go2nix-nix-plugin/, a Rust core and a C++ shim) that gives the default mode its package graph at eval time. It is built separately and has to be loaded into the evaluator.
Design context
go2nix builds Go applications at package granularity rather than treating
go build as a single opaque step. The approach is architecturally inspired
by Bazel’s rules_go — both systems work from an explicit package graph —
but go2nix has a much narrower scope: bring package-graph-aware Go builds to
Nix derivations and lockfiles, not replicate a full Bazel rule ecosystem.
For how go2nix compares to buildGoModule, gomod2nix, gobuild.nix, and
nix-gocacheprog, see the
comparison table in the README.
Builder modes
go2nix ships two builders that share the same lockfile and CLI but differ in when the package graph is discovered:
-
Default mode (
buildGoApplication) turns each Go package into its own Nix derivation. go2nix callsgo tool compileandgo tool linkdirectly instead ofgo build, giving Nix full control of the dependency graph at package granularity. The go2nix-nix-plugin (builtins.resolveGoPackages) discovers the package graph at eval time by runninggo listagainst the source tree, so when a dependency changes only the affected packages rebuild. -
Experimental mode (
buildGoApplicationExperimental) provides the same per-package granularity, but discovers the package graph at build time using recursive-nix and content-addressed derivations. Dependency discovery is deferred to the build, so no plugin is required.
See Builder Modes for the full comparison, requirements, and how to choose between them.
Nix directory layout
nix/
├── mk-go-env.nix # Entry point: creates Go toolchain scope
├── scope.nix # Self-referential package set (lib.makeScope)
├── stdlib.nix # Shared: compiled Go standard library
├── helpers.nix # Shared: sanitizeName, escapeModPath, etc.
├── dag/ # Default mode (eval-time DAG)
│ ├── default.nix # buildGoApplication
│ ├── fetch-go-module.nix # FOD fetcher (one module's extracted source tree)
│ └── hooks/ # Setup hooks (compile, link, env)
└── dynamic/ # Experimental mode (recursive-nix)
└── default.nix # buildGoApplicationExperimental
Entry point: mk-go-env.nix
goEnv = go2nix.lib.mkGoEnv { # == import ./nix/mk-go-env.nix inside this repo
inherit (pkgs) go callPackage;
go2nix = go2nix.packages.${system}.go2nix; # the CLI, not the flake
goEnv = { CGO_ENABLED = "0"; }; # optional, env for stdlib and every go tool call
netrcFile = null; # optional, for private modules
nixPackage = pkgs.nixVersions.nix_2_34; # optional, enables experimental mode
};
Creates a scope via scope.nix containing both builders plus shared
toolchain.
Package scope: scope.nix
Uses lib.makeScope newScope to create a self-referential package set.
Everything within the scope shares the same Go toolchain, goEnv, standard
library and go2nix binary. (mkGoEnv also accepts tags and stores it on the
scope, but neither builder reads it: build tags are the per-call tags
argument.)
Exposes:
buildGoApplication— default mode (eval-time per-package DAG)buildGoApplicationExperimental— experimental mode (recursive-nix)go,go2nix,stdlib,hooks,fetchers(fetchGoModule),helpers, andgoEnv(the env attrset, withGOOS/GOARCHdefaulted in when cross-compiling)
Shared: stdlib.nix
Compiles the entire Go standard library:
GODEBUG=installgoroot=all GOROOT="$NIX_BUILD_TOP" go install -v --trimpath std
after copying the toolchain’s src, pkg and lib there. Output:
$out/<pkg>.a for each stdlib package + $out/importcfg. There is one such
derivation per toolchain and scope goEnv (the variables are exported before
the build and a hash of them is part of the name), shared by every build in
the scope and by both modes.
Shared: helpers.nix
Pure Nix utility functions:
sanitizeName—/→-,~→_,@→_at_for derivation names, and anything longer than 160 characters is cut and given an 8-hex-digit hash suffix. The Go and Rust counterparts (pkg/nixdrv/sanitize.go,resolve.rs) additionally replace characters outside[a-zA-Z0-9+-._?=]; the three agree on valid import paths and must be kept in sync.removePrefix— Substring after a known prefix.escapeModPath— Go module case-escaping (A→!a).normalizeSubPackages— adds the missing./tosubPackagesentries.goModLocalReplaceDirs,parseLocalReplaces— read the filesystemreplacetargets out of ago.mod, for callers that build their own source filter.
Staleness detection
A lockfile is checked when it is generated and again at build time, by
link-binary; a module the graph needs and the lockfile lacks already fails
evaluation — see
Lockfile Format → Staleness detection
for the full table. The go2nix check subcommand can also be used standalone
to verify a lockfile without building.
Further reading
Builder Modes
A Go application is made up of modules (downloaded units, each with a
go.mod) and packages (individual directories of .go files within a
module). A single module can contain dozens of packages. Both builder modes
turn each package into its own Nix derivation; they differ in when the
package graph is discovered and what that requires of your Nix setup.
| Mode | How it works | Lockfile | Caching | Requires |
|---|---|---|---|---|
| Default | go tool compile/link per-package | optional (module hashes) | Per-package | go2nix-nix-plugin, built against the evaluating Nix (>= 2.34) |
| Experimental | Recursive-nix at build time | required ([mod] + optional [replace]) | Per-package | Nix >= 2.34 with dynamic-derivations, ca-derivations, recursive-nix |
-
Default (
buildGoApplication): every package (not just every module) gets its own derivation. go2nix callsgo tool compileandgo tool linkdirectly, bypassinggo build. The import graph is discovered at eval time by the go2nix-nix-plugin (builtins.resolveGoPackages), so the lockfile holds module hashes only, and can be left out altogether. When one package changes, only it and its reverse dependencies rebuild. -
Experimental (
buildGoApplicationExperimental): same per-package granularity as the default mode, but discovers the import graph at build time inside a recursive-nix wrapper instead of at eval time. The lockfile stays small ([mod]plus optional[replace]) and only changes when module resolution changes. Requires Nix >= 2.34 with experimental features enabled.
Choosing a mode
Use buildGoApplication (the default) for the best balance of caching and
simplicity — the lockfile is small (just module hashes), and the
go2nix-nix-plugin resolves the package graph at eval time.
Use buildGoApplicationExperimental only if you have Nix >= 2.34 with
dynamic-derivations, ca-derivations, and recursive-nix enabled, and
want per-package caching without requiring the plugin.
# Default (recommended):
goEnv.buildGoApplication { ... }
# Experimental (requires nix experimental features):
goEnv.buildGoApplicationExperimental { ... }
Default Mode
Per-package Nix derivations at eval time, with fine-grained caching.
Overview
The default mode creates per-package derivations from an eval-time package
graph. The go2nix-nix-plugin runs builtins.resolveGoPackages to discover
third-party packages, local packages, local replaces, module metadata, and
optional test-only packages when checks are enabled. Module hashes come from
the lockfile’s [mod] section, or from the plugin itself when there is no
lockfile; replace directives that point at another module change where a
module is fetched from. When a single dependency changes, only it and its
reverse dependencies rebuild.
Lockfile
The lockfile is optional. With one, module hashes are pinned in a file you review and commit:
go2nix generate .
It contains only module hashes — the package graph is resolved at eval time by the plugin, so it does not need to be regenerated when import relationships change, only when modules are added, removed or bumped.
Without one (goLock left out or null), the plugin computes the hashes
from go.sum and the module cache while evaluating; see
Lockfile-free builds.
Nix evaluation flow
1. Module hashes (builtins.fromTOML, or the plugin)
With a lockfile, it is parsed at eval time with builtins.fromTOML and module
metadata (path, version, hash) is read from its [mod] section; that is the
only section the Nix side reads. Without one, the same table comes from the
plugin’s moduleHashes.
2. Package graph discovery (builtins.resolveGoPackages)
The go2nix-nix-plugin runs go list -json -deps against the source tree at
eval time and returns the package graph (third-party, local, test-only, and
replacement metadata) — see Nix Plugin
for the full return shape.
replace directives that point at another module come back as the plugin’s
replacements and rewrite each module’s fetchPath and version, so that
FODs download from the right place. Modules replaced with a directory are not
fetched at all: their packages are local packages.
3. Module fetching (fetch-go-module.nix)
Each module is a fixed-output derivation (FOD) that runs go mod download and
keeps only the extracted source tree: $out is the module’s directory
(what would be <escaped-path>@<version>/ in a module cache), without the
cache/download metadata, so the hash does not depend on which proxy served
it.
GOPROXY and NETRC are inherited from the builder’s environment unless the
goProxy argument pins a proxy in the derivation; the netrcFile option
supports private module authentication.
4. Package derivations (default.nix)
For each third-party package in goPackagesResult.packages, a derivation is
created. nix/dag builds a JSON compile manifest for it (the importcfg
parts of its dependencies and of the standard library, build tags, gcflags,
the PGO profile, the package’s file lists) and the derivation passes it to
go2nix compile-package --manifest.
Pure-Go packages — almost all of them — are a bare builtins.derivation
whose builder is bash running a short inline script: no stdenv, no phases,
just go and coreutils on PATH. CGO packages (where pkg.isCgo is true)
are a stdenv.mkDerivation using the compile-go-pkg.sh setup hook (from
nix/dag/hooks/), with stdenv.cc added to nativeBuildInputs.
Dependencies (deps) are resolved lazily via Nix’s laziness — each package
references other packages from the same packages attrset.
5. Local package derivations
Each local package in goPackagesResult.localPackages (and, under doCheck,
testLocalPackages) also gets its own derivation. Its source is a copy of
that package’s directory only — rooted at the directory itself, with nested
package directories and nested modules left out, and the caller’s srcFilter
applied — so editing a neighbouring package does not change it. Local package
dependencies can point to other local packages and to third-party packages.
Packages of a module replaced with a directory compile with that module’s own
go directive and module@version. With contentAddressed = true these are
the derivations that become content-addressed and gain an iface output.
6. Importcfg bundles
Instead of passing every compiled package as a direct dependency of the final
application derivation, the default mode builds bundled importcfg derivations:
depsImportcfg: stdlib + third-party packages. Local packages are not in it; their archives reach the link through the link manifest, so a local edit leaves the bundle untouchedtestDepsImportcfg: adds test-only third-party packages whendoCheck = true
This keeps the final derivation’s input fan-in small while preserving fine-grained package caching.
7. Application derivation
The final derivation receives typed JSON manifests via environment variables
and uses goAppHook (link-go-binary.sh) to invoke the Go CLI:
- Build phase — writes
linkManifestJSONto a file and callsgo2nix link-binary --manifest, which validates the lockfile againstgo.mod(when there is a lockfile), generates modinfo, compiles main packages, and invokes the linker. - Check phase — writes
testManifestJSONto a file and callsgo2nix test-packages --manifest, which discovers testable local packages, compiles test archives, and runs them.
Package overrides
Per-package customization (nativeBuildInputs for cgo packages, env,
srcOverlay) is supported via packageOverrides — see
Package Overrides for lookup rules and recipes.
Directory layout
nix/dag/
├── default.nix # buildGoApplication
├── fetch-go-module.nix # FOD fetcher
└── hooks/
├── default.nix # Hook definitions
├── setup-go-env.sh # GOPROXY=off, GOSUMDB=off
├── compile-go-pkg.sh # Compile one cgo package (pure-Go ones skip stdenv)
└── link-go-binary.sh # Link binary and run checks
Trade-offs
Pros:
- Fine-grained caching — changing one dependency doesn’t rebuild everything
- No experimental Nix features required (
contentAddressed = trueis opt-in and needsca-derivations) - Small lockfile (module hashes only), or none at all
- Lockfile only changes when modules are added, removed or bumped, not when imports change
- Automatic CGO detection and compiler injection
Cons:
- Requires the go2nix-nix-plugin (provides
builtins.resolveGoPackages), built against the Nix that evaluates (2.34 or newer) - Many small derivations can slow Nix evaluation on very large projects
Compilation and linking are handled by the builder hooks and direct
go tool compile / go tool link invocations described above.
Experimental Mode
Per-package CA derivations at build time, via recursive-nix.
Overview
The experimental mode moves package graph discovery from Nix eval time to build
time. A single recursive-nix wrapper derivation runs go2nix resolve, which
calls go list -json -deps to discover the import graph, then registers one
content-addressed (CA) derivation per package with the Nix daemon (over its
socket; nix derivation add is the fallback when no daemon is reachable). The
wrapper’s output is a .drv file; builtins.outputOf resolves it to the
final binary at eval time.
Because derivations are content-addressed, a change that doesn’t affect the compiled output (e.g., editing a comment) won’t propagate rebuilds — Nix deduplicates by content hash.
Requirements
The experimental mode requires Nix >= 2.34 with these experimental features enabled:
extra-experimental-features = recursive-nix ca-derivations dynamic-derivations
mkGoEnv must also be given nixPackage — the Nix that runs inside the
wrapper derivation, and the one the “>= 2.34” check is made against;
buildGoApplicationExperimental throws without it. The wrapper asks for the
recursive-nix system feature, so the machine that builds it has to offer
it.
What this builder does not do (none of it is an error, the attributes are
simply ignored): it runs no tests (doCheck, checkFlags), has no
lockfile-free mode (goLock is required), and ignores version, meta,
goProxy, allowGoReference, contentAddressed, extraMainSrcFiles,
srcFilter, env and passthru. CGO_ENABLED does not default to the
scope’s goEnv.CGO_ENABLED, and the scope’s goEnv reaches the standard
library and go2nix resolve but not the per-package derivations. The whole
src is an input of the wrapper, so any change to it re-runs
go2nix resolve, even though the per-package derivations it registers are
then reused.
Lockfile requirements
The experimental mode uses the same lockfile format as the default mode:
go2nix generate .
The package graph is discovered at build time, so the lockfile does not store
package-level dependency data. It contains [mod] hashes and optional
[replace] entries. See lockfile-format.md for
details.
Build flow
1. Wrapper derivation (eval time)
Nix evaluates a text-mode CA derivation (${pname}.drv) that will run
go2nix resolve at build time. All inputs (Go toolchain, go2nix, Nix binary,
source, lockfile) are captured as derivation inputs.
2. Module FODs (build time)
go2nix resolve reads [mod] from the lockfile and creates fixed-output
derivations for each module, then builds them inside the recursive-nix
sandbox. Each FOD runs go mod download and keeps the module’s extracted
source tree, the same output as the default mode’s fetcher; go2nix resolve
then assembles a module cache from them. The netrcFile option (an argument
of mkGoEnv) supports private module authentication; like any input of a
fixed-output derivation it ends up in the store, see
Private modules.
3. Package graph discovery (build time)
With all modules available, go list -json -deps discovers the full import
graph. The default mode performs this step at eval time via the
go2nix-nix-plugin — the experimental mode defers it to build time inside the
recursive-nix sandbox.
4. CA derivation registration (build time)
For each package, go2nix resolve registers a content-addressed derivation that compiles one Go package to an archive
(.a file). Dependencies between packages are expressed as derivation inputs.
Local packages are also individual CA derivations.
5. Link derivation (build time)
A final CA derivation links all compiled packages into the output binary. For multi-binary projects, a collector derivation aggregates multiple link outputs.
6. Output resolution (eval time)
The wrapper’s output is the .drv file path. builtins.outputOf tells Nix
to build that derivation and use its output, connecting eval time to the
build-time-generated derivation graph.
Package overrides
packageOverrides is serialized to JSON and passed to go2nix resolve,
which adds the extra inputs to the appropriate CA derivations. Only
nativeBuildInputs is forwarded; env (and any other key) is rejected at
eval time because derivations are synthesized at build time and only store
paths can cross that boundary. See
Package Overrides for lookup rules and recipes.
Usage
goEnv.buildGoApplicationExperimental {
src = ./.;
goLock = ./go2nix.toml;
pname = "my-app";
subPackages = [ "cmd/server" ];
tags = [ "nethttpomithttp2" ];
ldflags = [ "-s" "-w" ];
}
The result has a target passthru attribute containing the final binary,
resolved via builtins.outputOf.
Directory layout
nix/dynamic/
└── default.nix # buildGoApplicationExperimental (wrapper derivation)
The build-time logic lives in the go2nix resolve command
(see cli-reference.md).
Trade-offs (vs default mode)
Pros:
- No Nix plugin required — package graph discovery happens in a hermetic
build, not via an impure eval-time
go list - CA deduplication is always on — comment-only edits don’t trigger rebuilds
- Same small lockfile and per-package caching as default mode
Cons:
- Requires Nix >= 2.34 with experimental features (
recursive-nix,ca-derivations,dynamic-derivations) - Build-time overhead from derivation registration:
.drvpaths are computed in-process (microseconds each) and registered concurrently over the nix-daemon socket; the per-derivationnix derivation addsubprocess is only used as a fallback when no daemon is reachable
Performance and scaling characteristics depend on recursive-nix support, content-addressed derivations, and daemon round-trip latency.
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)
The same thing by layer:
module FODs one fixed-output derivation (FOD)
│ per module@version the build uses
▼
third-party package drvs (.a) one per imported third-party package
│ │
▼ ▼
local package drvs importcfg bundle bundle = stdlib + third-party
(.a; plus .x with (one per app) entries, no local packages
contentAddressed) │
│ │
└──────────┬───────────┘
▼
app drv compiles the main package(s), links,
and runs the tests (doCheck)
stdlib drv ──► an input of every compile and of the bundle
(Go toolchain + scope goEnv only)
.a = compiled package archive; .x = export-data interface, which only
local packages get and only with contentAddressed = true (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 and scope goEnv | Go version + goEnv (GOOS/GOARCH when cross-compiling, CGO_ENABLED, GOFIPS140, …) | Go is bumped or goEnv changes |
| module FOD | module path@version the build uses | module path@version + NAR hash | that module is bumped |
| third-party package | imported package | module FOD + import deps + tags + gcflags | the module or any of its transitive deps change |
| local package | local Go package | the package’s directory (every file in it, _test.go and testdata/ included, nested packages excluded) + import deps | any file in that directory or a dep changes |
| importcfg bundle | app | the stdlib and the third-party package outputs | a third-party package output changes |
| app | app | importcfg bundle + local package archives + the filtered source (mainSrc: with doCheck, every local package’s sources, tests and testdata/) | 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 is hashed — its whole subtree, minus nested package
directories and nested modules, and whatever srcFilter rejects — so editing
pkg/a/a.go does not change the input hash of the pkg/b derivation unless
b imports a. No go.mod comes along (the go directive is passed in
separately). Everything in the directory counts, not just what gets compiled:
embedded assets, but also _test.go files, testdata/ and a README next to
the sources participate in the package’s 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 final derivation rebuilds: it compiles the main package, links, and runs the tests. The importcfg bundle does not: it only holds standard library and third-party entries, which a local edit does not touch.
Packages outside the cone keep their existing store paths and are not rebuilt.
The same reasoning for other kinds of change:
| You change | What rebuilds |
|---|---|
a file in a local package’s directory (source, test or testdata/) | that package, the local packages that import it directly or transitively, and the app. With contentAddressed = true the dependents are skipped when the package’s export data came out the same, which is always the case for a test-only edit |
| an import between packages that already exist | the importing package and its cone. Nothing to regenerate: the lockfile pins modules, not the graph |
| one module’s version | its FOD, the packages of that module, everything that imports them, the importcfg bundle and the app |
| a test-only dependency | its testPackages derivations, the test importcfg bundle and the app (relink, tests re-run) |
ldflags, checkFlags | the app only |
tags, gcflags, pgoProfile | every package compile (they are part of each compile manifest), then the bundle and the app. The stdlib is not affected |
the scope’s goEnv, or the Go toolchain | the stdlib, and everything after it |
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. Local-package derivations and the importcfg bundle
become content-addressed, so a rebuild that produces a byte-identical
.aresolves to the same store path and short-circuits downstream rebuilds. Third-party packages stay input-addressed: their source never changes, so CA would only add resolution overhead. ifaceoutput split. Each local-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 — and a secondgo list -deps -testpass whendoCheckis on, which is the default. - 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.
Benchmarking
bench-incremental measures rebuild time for go2nix’s default-mode builder
after touching a single file at different depths in the dependency graph.
Use it to see how contentAddressed and the iface early-cutoff behave on
a representative project before adopting them.
Running it
nix run .#bench-incremental -- -fixture light
Run it from a go2nix checkout: the harness takes the fixtures, the plugin and
lib.nix from the git repository around the current directory (it calls
git rev-parse --show-toplevel), so that checkout is what gets measured.
It needs git and go on PATH and uses your GOMODCACHE; NIXPKGS_PATH
selects the nixpkgs it evaluates with.
The harness spins up a rooted local store
(NIX_REMOTE=local?root=$TMPDIR/...), loads the
Nix plugin via --option plugin-files, does a full warm
build, then repeatedly edits one file and times the rebuild. It needs
network access (the warm build fetches modules from substituters), so it
cannot run inside a nix build sandbox — nix flake check only
verifies that the binary links.
Flags
| Flag | Default | Meaning |
|---|---|---|
-runs N | 3 | Runs per scenario; mean ± stddev and min..max are reported |
-scenario S | all | One of no_change, leaf, mid, deep, all |
-touch-mode M | private | private edits an unexported symbol; exported edits an exported one |
-tools L | nix-nocgo,nix-ca-nocgo | Comma-separated tool variants: nix, nix-ca, nix-nocgo, nix-ca-nocgo, and bazel (torture fixture only) |
-fixture F | light | light or torture (see below) |
-json PATH | — | Write raw results as JSON |
-assert-cascade N | — | Fail (non-zero exit) if any tool builds more than N derivations on a touch scenario |
-stderr-tail N | 500 | Bytes of a failing command’s stderr kept in the error message |
The nix-ca* variants set contentAddressed = true; the *-nocgo
variants set CGO_ENABLED = 0. Comparing nix-nocgo against
nix-ca-nocgo with -touch-mode private shows the iface early-cutoff in
action.
Fixtures and scenarios
Two synthetic projects under tests/fixtures/:
| Fixture | Shape | leaf edits | mid edits | deep edits |
|---|---|---|---|---|
light | small app, a few internal packages | app/cmd/app/main.go | internal/handler/handler.go | internal/core/core.go |
torture | large app, hundreds of modules | app-full/cmd/app-full/main.go | internal/aws/aws.go | internal/common/common.go |
leaf touches the entrypoint (no reverse dependents — only the link
rebuilds). mid touches a package roughly halfway up the graph with a
moderate reverse-dependency cone. deep touches a package near the bottom
of the graph that fans out to most of the app. no_change measures the
eval + no-op-build floor.
Using -assert-cascade in CI
nix run .#bench-incremental -- \
-fixture light -scenario mid -touch-mode private \
-tools nix-ca-nocgo -assert-cascade 5
This fails if a private-symbol edit to a mid-graph package causes more than five derivations to rebuild — a regression check for the early-cutoff machinery.
The repository’s own CI does something else: .github/workflows/benchmark.yml
runs the harness with --json on the base branch and on the pull request and
compares them with scripts/compare-benchmarks.py --threshold 20, which
flags a mean time more than 20% worse or any increase in derivations built.
Other benchmarks
The flake also exposes coarser-grained harnesses:
benchmark-build—buildGoModulevs go2nix vs the experimental builder with hyperfine, over three phases: clean build, cached rebuild, rebuild after a source change.benchmark-eval—nix-instantiatetime of the default builder vs the experimental one (plugin + instantiation cost).benchmark-build-cross-app-isolation— verifies that two apps sharing third-party packages reuse each other’s per-package store paths.