Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

The derivations of one go2nix build: module fetches (gomod) feed third-party package compiles (gopkg), which feed the compiles of your own packages (golocal), which feed the application derivation that links and runs the tests; the standard library is one derivation read by every compile, and an importcfg bundle collects the third-party entries for the link. Editing internal/web rebuilds internal/web, cmd/app and the application only.

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 build fails with error: attribute 'resolveGoPackages' missing.

Getting Started goes from an empty directory to a built binary, with the output of every step. In short:

  1. nix run github:numtide/go2nix -- generate . writes go2nix.toml, one hash per module (optional: goLock = null derives them from go.sum).
  2. go2nix.lib.mkGoEnv { … } in your flake gives you a scope, and goEnv.buildGoApplication { src = ./.; goLock = ./go2nix.toml; pname = …; } describes the application.
  3. nix build, with the plugin loaded through --option plugin-files or nix.conf.

Where to next

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-linux or aarch64-darwin.
  • The go2nix Nix plugin loaded into the evaluator; step 4 shows how. It builds against Nix 2.34 or newer.
  • A go on PATH for the two steps that talk to the module proxy: creating the module and go2nix generate. nix shell nixpkgs#go is 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:

DerivationWhat it is
go-stdlib-…the standard library, compiled once per toolchain and goEnv
gomod-github.com-fatih-color-v1.18.0 and three moreone 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.0one compile per third-party package that main.go reaches: x/sys is one package here, not the module
golocal-example.com-my-appone compile per package of yours
my-app-deps-importcfg, my-app-test-deps-importcfgthe import maps the link and the tests read
my-app-0.1.0compiles 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

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.

WhereWhenSees
go2nix generateyou run ityour whole environment: GOPROXY, GOPRIVATE, ~/.netrc, your git and its credentials. It turns the checksum database off itself.
go list, in the pluginevery evaluationGOMODCACHE, 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 derivationsthe buildGOPROXY 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";
};
  • goProxy puts 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-time go list uses.
  • netrcFile ends 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 (or NETRC), or the modules already in GOMODCACHE. A CI job that runs go mod download before nix build satisfies that.
  • Fetching straight from a private Git host (GOPRIVATE, direct) works for generate on your machine and nowhere else: the fetch derivations have no git. 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:

  1. The package’s exact import path (e.g. "github.com/diamondburned/gotk4/pkg/core/glib").
  2. The package’s module path (e.g. "github.com/diamondburned/gotk4/pkg").
  3. 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 replace with 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

KeyTypeDefault modeExperimental modeNotes
nativeBuildInputslist of derivationscgo packages onlyyesDefault 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
envattrsetyesnoExtra environment variables on the per-package derivation
srcOverlayderivation or pathyesnoContents 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 PATHnativeBuildInputs 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:

  1. Internal test compilation — library source files + _test.go files in the same package are compiled together into a single archive that replaces the library archive.
  2. 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).
  3. External test compilation_test.go files in the *_test package (xtests) are compiled as a separate package that imports the internal test archive.
  4. Test main generation — a _testmain.go is generated that registers all Test*, Benchmark*, Fuzz*, and Example* functions.
  5. 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.go files 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.go files in the _test package. These can only access exported identifiers and test the public API. They are compiled as a separate package (foo_test) that imports foo.

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.go files) 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.go files) 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 subPackages closure (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 test extras. No coverage instrumentation, no vet pass, no default 10-minute timeout, and packages are tested one after another.
  • ldflags do 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.

buildGoModulego2nixNotes
pname, version, src, meta, passthruthe same
vendorHashgoLock = ./go2nix.toml, or goLock = nullOne 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, overrideModAttrsnone neededThere 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.
modRootmodRootSame meaning. Default ".".
subPackagessubPackagesThe 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.
excludedPackagesnoneSay what to build with subPackages instead.
ldflagsldflagsPassed to go tool link.
tagstagsPer build, not on mkGoEnv.
GOFLAGSnoneNothing here runs go build, so there is no GOFLAGS to read. -trimpath is always on; compiler flags go in gcflags.
env.CGO_ENABLEDCGO_ENABLED, or goEnv.CGO_ENABLED on the scopeThe 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>.nativeBuildInputsThe 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.
doCheckdoCheckBoth 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.
buildTestBinariesnone
allowGoReferenceallowGoReferenceSame meaning and default.
enableParallelBuildingnoneParallelism is Nix building independent package derivations.
preBuild, postInstall, other phases and hookspassed throughThey 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 preBuildpackageOverrides.<import path>.srcOverlay, or commit the generated filesA 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.….buildGoModulepkgsCross.….callPackage into mkGoEnv, or goEnv.GOOS/GOARCHSee Cross-compilation.

What changes

  • One derivation becomes many. nix build output, nix log, and nix why-depends now talk about gopkg-… and golocal-… derivations. A compile error is in the log of the package that failed; see Debugging a build.
  • Evaluation does work. The plugin runs go list every time Nix evaluates the expression, so the modules must be in GOMODCACHE or 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 vet pass. 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;
}
gomod2nixgo2nixNotes
gomod2nix.toml, from gomod2nix generatego2nix.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.tomlgoLock = ./go2nix.tomlOr goLock = null.
pwdnot neededsrc and modRoot say where go.mod is.
buildGoApplication from an overlaybuildGoApplication from the scope mkGoEnv returnsSame name, different function. There is no overlay; the scope is the API.
mkGoEnv { pwd = ./.; }, a development shellno equivalent; go2nix.lib.mkGoEnv is something elseIn 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, doCheckas 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
NameWhat 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-importcfgconcatenating 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 in GOMODCACHE and could not be downloaded where Nix evaluates. The evaluation-time go list inherits GOMODCACHE, GOPATH, HOME, GOPROXY, NETRC and the TLS certificate variables, and nothing else: no GOPRIVATE, no GONOSUMDB, no HTTPS_PROXY, and go env -w settings do not apply (GOENV=off). goProxy on the build overrides GOPROXY. Run go mod download in 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): the go line of go.mod asks for a newer Go than the one in your scope. Lower the directive or use a newer go in mkGoEnv; nix eval --raw nixpkgs#go.version tells 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 not git add-ed yet (flakes only copy tracked files), or one that srcFilter or a lib.fileset leaves out.
  • go.mod or go.sum is not tidy. missing go.sum entry for module providing package …, or cannot find module providing package … for a third-party import: run go 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 …): check tags, goEnv.GOOS/GOARCH and CGO_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_ENABLED isn’t forced to 0 and that the cgo files aren’t excluded by build tags on your target platform.
  • If you need to influence a pure-Go compile, use env instead.
  • If you only need the inputs at link time, put them in the top-level nativeBuildInputs of buildGoApplication rather than in packageOverrides.

See Package Overrides.

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.

AttributeTypeModesDescription
srcpathbothSee above.
pnamestringbothPackage name for the output derivation.
versionstringdefault onlyPackage version. The experimental builder ignores it (its wrapper produces a CA .drv whose name is derived from pname alone).

Optional attributes

AttributeTypeDefaultModesDescription
goLockpath or nullnull (default) / required (experimental)bothPath to go2nix.toml. In default mode, null enables lockfile-free builds. The experimental builder requires a lockfile.
subPackageslist of strings[ "." ]bothPackages to build, relative to modRoot. A ./ prefix is auto-added if missing.
modRootstring"."bothSubdirectory within src containing go.mod.
tagslist of strings[]bothGo build tags.
ldflagslist of strings[]bothFlags passed to go tool link (-s, -w, -X, etc.).
gcflagslist of strings[]bothExtra flags passed to go tool compile.
CGO_ENABLED0, 1, or nullthe scope’s goEnv.CGO_ENABLED if set, else null (default mode); null (experimental)bothValue 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".
pgoProfilepath or nullnullbothPath 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.
nativeBuildInputslist[]bothExtra 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.
packageOverridesattrset{}bothPer-package customization (see below).
doCheckbooltruedefault onlyRun tests. Matches buildGoModule’s default. See Test Support.
checkFlagslist of strings[]default onlyFlags 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.
extraMainSrcFileslist of strings[]default onlyExtra 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.
srcFilterfunction path: type: boolkeep everythingdefault onlyExtra 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.
goProxystring or nullnulldefault onlyGOPROXY 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).
allowGoReferenceboolfalsedefault onlyAllow the output to reference the Go toolchain. The output may never reference the filtered source tree (mainSrc); both are enforced with disallowedReferences.
metaattrset{}default onlyNix meta attributes.
contentAddressedboolfalsedefault onlyMake 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 nameHow 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 doCheckone 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
};
AttributeTypeDefaultDescription
goderivationrequiredGo toolchain.
go2nixderivationrequiredgo2nix CLI binary.
callPackagefunctionrequiredpkgs.callPackage.
tagslist of strings[]Stored on the scope, but neither builder reads it: pass tags to each buildGoApplication call.
goEnvattrset{}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.
netrcFilepath or nullnull.netrc file for private module authentication (see below).
nixPackagederivation or nullnullNix 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 DefaultGODEBUGgo 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

MemberWhat it is
buildGoApplicationThe default builder. See Builder API.
buildGoApplicationExperimentalThe experimental builder; throws unless mkGoEnv was given nixPackage. See Experimental Mode.
go, go2nix, nixPackage, netrcFileWhat mkGoEnv was given.
goEnvWhat mkGoEnv was given, with GOOS and GOARCH filled in from the host platform when cross-compiling.
stdlibThe compiled standard library; below.
fetchers.fetchGoModuleThe function that makes a module’s fetch derivation; below.
hooksSetup hooks used by the derivations that need stdenv; below.
helpersPure functions shared by the builders; below.
tags, tagFlagThe tags given to mkGoEnv and their comma-joined form. Neither builder reads them; pass tags to each build.
hasDynamicDerivationsbuiltins ? outputOf: whether this Nix can evaluate the experimental builder’s result.
callPackage, newScope, overrideScope, packages, libFrom 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>.a for every standard-library package
  • $out/importcfg, one packagefile <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:

  • GOPROXY and NETRC are inherited from the environment of whatever runs the build (impureEnvVars), along with the usual proxy variables. goProxy exports GOPROXY inside the derivation instead, which is what you want under a daemon or a remote builder, where that environment is not yours.
  • The scope’s netrcFile is copied to $HOME/.netrc.
  • GOSUMDB=off: the fixed output hash is the integrity check.
  • Only go and CA certificates are on PATH. There is no git, so a module that direct would 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.

HookUsed byReads
setupGoEnvboth hooks belowsets HOME, GOPROXY=off, GOSUMDB=off, GONOSUMCHECK=* before configure
goModuleHookcgo package derivationsgoPackagePath, goPackageSrcDir, goLangVersion, goModulePath, goModuleVersion, compileManifestJSON, optionally goSrcOverlay; writes the iface output when the derivation has one
goAppHookthe application derivationlinkManifestJSON, and testManifestJSON when doCheck is on; installs $out/bin/*

helpers

FunctionContract
sanitizeName ss 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 sGo’s module path escaping: every upper-case letter becomes ! and its lower-case form, the layout of GOMODCACHE.
normalizeSubPackages listPrefixes ./ 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 textThe filesystem targets (./…, ../…) of the replace directives in the text of a go.mod.
goModLocalReplaceDirs dirdir plus every directory reachable from its go.mod through filesystem replace directives, transitively.
removePrefix prefix ss 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 lists go list reported, by kind), and when they apply isCgo, cgoPkgConfig, cgoCflags, cgoLdflags, and localImports (imports that resolve to local packages, which happens when the main go.mod replaces a module the package imports with a directory)
  • localPackages — packages of the main module and of modules it replaces with a directory: dir (relative to src, "." 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 fields
  • testPackages, testLocalPackages — the same two shapes for what only the tests reach; testPackages is { } without doCheck, and testLocalPackages is left out when empty
  • modulePath — the main module’s import path
  • goVersion — the main module’s go directive (e.g. "1.25"); the dag builder threads this as -lang to local-package compiles
  • replacements — 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 are siblingModules
  • siblingModules — modules replaced with a directory, by module path: path, version (from the require line), goVersion, replaceDir; left out when there are none
  • localReplaceDirs, nestedModuleRoots — the replace target directories (followed transitively) and every directory under them and modRoot that holds a go.mod, both relative to src
  • subPackageClosures — per main package: modKeys and siblingModPaths (the modules it links, for the embedded module info) and cxx (whether it needs a C++ linker)
  • moduleHashes — module NAR hashes (when resolveHashes = 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 wraps go 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...]
FlagDefaultDescription
-ogo2nix.tomlOutput lockfile path
-jNumCPUMax 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]
FlagDefaultDescription
--lockfilego2nix.tomlPath 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]
FlagRequiredDescription
--manifestYesPath to compile-manifest.json
--import-pathYesGo import path for the package
--src-dirYesDirectory containing source files
--outputYesOutput .a archive path
--iface-outputNoWrite export-data-only interface (.x) to this path; --output then receives the link object via -linkobj
--importcfg-outputNoWrite importcfg entry for consumers to this path
--trim-pathNoPath prefix to trim (default: $NIX_BUILD_TOP)
--pNoOverride -p flag (default: import-path)
--go-versionNoGo language version for -lang (default: read from go.mod)
--module-pathNoOwning module’s path; with --module-version, source paths are rewritten to <module>@<version>/... as go build -trimpath does
--module-versionNoOwning module’s version (from the require line); empty for main-module packages, which rewrite to the import path

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
FlagRequiredDescription
--manifestYesPath to link-manifest.json
--outputYesOutput 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
FlagRequiredDescription
--manifestYesPath 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]
FlagRequiredDescription
--srcYesStore path to Go source
--mod-rootNoSubdirectory within src containing go.mod
--lockfileYesPath to go2nix.toml lockfile
--systemYesNix system (e.g., x86_64-linux)
--goYesPath to go binary
--nixYesPath to nix binary
--pnameYesOutput binary name
--outputYes$out path
--stdlibYesPath to pre-compiled Go stdlib
--go2nixNoPath to go2nix binary (defaults to self)
--bashNoPath to bash binary
--coreutilsNoPath to a coreutils binary (e.g., coreutils/bin/mkdir)
--sub-packagesNoComma-separated sub-packages
--tagsNoComma-separated build tags
--ldflagsNoLinker flags
--cgo-enabledNoOverride CGO_ENABLED (0 or 1)
--gcflagsNoExtra flags for go tool compile
--pgo-profileNoStore path to pprof CPU profile for PGO
--overridesNoJSON-encoded packageOverrides
--cacertNoPath to CA certificate bundle
--netrc-fileNoPath to .netrc for private modules
--nix-jobsNoMax concurrent derivation registrations
--daemon-socketNonix-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>
FlagRequiredDescription
--lockfileYesPath to go2nix.toml lockfile
--goNoPath to go binary (default: from PATH)
--main-pathNoImport path of the main package (default: the module path)
--main-dirNoDirectory 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]
FlagRequiredDescription
--import-pathYesImport path of the package under test
--module-pathNoModule path of the main module
--test-filesNoComma-separated absolute paths to internal _test.go files
--xtest-filesNoComma-separated absolute paths to external _test.go files
--outputNoOutput 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 require line in go.mod
  • a replace directive 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 when goLock = null — there is no lockfile for go.mod to drift from. Module versions are read live from go.sum via the plugin on every evaluation, so a go get is reflected on the next nix build with nothing to regenerate. The standalone go2nix check subcommand compares go.mod with 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

WhenWhatApplies toHow
GenerationThe module setAll modesgenerate 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 evalPackage graph, and the modules it usesDefault onlybuiltins.resolveGoPackages runs go list at eval time; a module the graph uses and the lockfile lacks fails with attribute '"<module>@<version>"' missing
Build timeLockfile consistencyDefault, with lockfilelink-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:

  1. 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.
  2. A Nix library (nix/) that turns the package graph into derivations, in one of two modes.
  3. 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 calls go tool compile and go tool link directly instead of go 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 running go list against 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, and goEnv (the env attrset, with GOOS/GOARCH defaulted 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 ./ to subPackages entries.
  • goModLocalReplaceDirs, parseLocalReplaces — read the filesystem replace targets out of a go.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.

ModeHow it worksLockfileCachingRequires
Defaultgo tool compile/link per-packageoptional (module hashes)Per-packagego2nix-nix-plugin, built against the evaluating Nix (>= 2.34)
ExperimentalRecursive-nix at build timerequired ([mod] + optional [replace])Per-packageNix >= 2.34 with dynamic-derivations, ca-derivations, recursive-nix
The two modes side by side. Default: builtins.resolveGoPackages runs go list during evaluation, so every per-package derivation exists before the build, which fetches, compiles, links and tests. Experimental: evaluation yields one wrapper derivation and a .target resolved with builtins.outputOf; at build time go2nix resolve runs inside recursive-nix, fetches modules, runs go list and registers one content-addressed derivation per package, then Nix builds the resulting .drv.
  • Default (buildGoApplication): every package (not just every module) gets its own derivation. go2nix calls go tool compile and go tool link directly, bypassing go 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.

Default mode in two lanes. Evaluation: the optional go2nix.toml, then builtins.resolveGoPackages running go list (and a -test pass with doCheck), then the package graph, then one derivation per module, package and bundle plus the application. Build: module fetches and the standard library both feed the per-package compiles, then the importcfg bundle, then the application derivation, which links and runs the tests.

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 untouched
  • testDepsImportcfg: adds test-only third-party packages when doCheck = 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:

  1. Build phase — writes linkManifestJSON to a file and calls go2nix link-binary --manifest, which validates the lockfile against go.mod (when there is a lockfile), generates modinfo, compiles main packages, and invokes the linker.
  2. Check phase — writes testManifestJSON to a file and calls go2nix 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 = true is opt-in and needs ca-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.

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: .drv paths are computed in-process (microseconds each) and registered concurrently over the nix-daemon socket; the per-derivation nix derivation add subprocess 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 derivations of one go2nix build: module fetches (gomod) feed third-party package compiles (gopkg), which feed the compiles of your own packages (golocal), which feed the application derivation that links and runs the tests; the standard library is one derivation read by every compile, and an importcfg bundle collects the third-party entries for the link. Editing internal/web rebuilds internal/web, cmd/app and the application only.

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

LayerOne derivation perCache key (informally)Rebuilds when
stdlibGo toolchain and scope goEnvGo version + goEnv (GOOS/GOARCH when cross-compiling, CGO_ENABLED, GOFIPS140, …)Go is bumped or goEnv changes
module FODmodule path@version the build usesmodule path@version + NAR hashthat module is bumped
third-party packageimported packagemodule FOD + import deps + tags + gcflagsthe module or any of its transitive deps change
local packagelocal Go packagethe package’s directory (every file in it, _test.go and testdata/ included, nested packages excluded) + import depsany file in that directory or a dep changes
importcfg bundleappthe stdlib and the third-party package outputsa third-party package output changes
appappimportcfg 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:

  1. The edited package recompiles.
  2. Each package that imports it (directly or transitively) recompiles.
  3. 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 changeWhat 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 existthe importing package and its cone. Nothing to regenerate: the lockfile pins modules, not the graph
one module’s versionits FOD, the packages of that module, everything that imports them, the importcfg bundle and the app
a test-only dependencyits testPackages derivations, the test importcfg bundle and the app (relink, tests re-run)
ldflags, checkFlagsthe app only
tags, gcflags, pgoProfileevery 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 toolchainthe 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 .a resolves 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.
  • iface output split. Each local-package derivation gains a second iface output containing only the export data (the .x file produced by go tool compile -linkobj). Downstream compiles depend on iface instead of the full .a, so changes to private symbols that don’t alter the package’s exported API don’t cascade. This mirrors the .x model used by Bazel’s rules_go.
The same private edit to internal/web built twice. Input-addressed, the default: internal/web, cmd/app and the application all rebuild. With contentAddressed = true, internal/web has an archive output (.a), which changes, and an interface output (.x), which comes out byte-identical and keeps its store path; cmd/app compiles against the .x and is reused, and only the link, which needs the .a, runs again.

The two are coupled by design: CA without iface only short-circuits comment-only edits, and iface without CA can’t cut off anything because the input-addressed .x path still changes whenever src changes.

Requires the ca-derivations experimental feature in Nix. The final binary stays input-addressed.

Known limitation: adding the first package-level initializer to a previously init-free package still flips a bit in the .x file, so that particular edit cascades even though the API didn’t change. This is rare in practice.

The cost: eval time

go2nix trades build time for eval time. Every nix build evaluation:

  1. Calls builtins.resolveGoPackages (the Nix plugin), which runs go list -json -deps against your source tree — and a second go list -deps -test pass when doCheck is on, which is the default.
  2. Instantiates one derivation per package in the resulting graph.

For a large application (~3,500 packages) the warm-cache go list step takes on the order of a few hundred milliseconds, and instantiation adds a similar amount on top. This is the floor on every rebuild — even a no-change rebuild — and is the main reason go2nix is overkill for small single-binary projects.

The plugin call is impure (it reads GOMODCACHE), so the result is not cached by the Nix evaluator across invocations. See Nix Plugin for details.

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

FlagDefaultMeaning
-runs N3Runs per scenario; mean ± stddev and min..max are reported
-scenario SallOne of no_change, leaf, mid, deep, all
-touch-mode Mprivateprivate edits an unexported symbol; exported edits an exported one
-tools Lnix-nocgo,nix-ca-nocgoComma-separated tool variants: nix, nix-ca, nix-nocgo, nix-ca-nocgo, and bazel (torture fixture only)
-fixture Flightlight or torture (see below)
-json PATHWrite raw results as JSON
-assert-cascade NFail (non-zero exit) if any tool builds more than N derivations on a touch scenario
-stderr-tail N500Bytes 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/:

FixtureShapeleaf editsmid editsdeep edits
lightsmall app, a few internal packagesapp/cmd/app/main.gointernal/handler/handler.gointernal/core/core.go
torturelarge app, hundreds of modulesapp-full/cmd/app-full/main.gointernal/aws/aws.gointernal/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-buildbuildGoModule vs go2nix vs the experimental builder with hyperfine, over three phases: clean build, cached rebuild, rebuild after a source change.
  • benchmark-evalnix-instantiate time 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.