How do I make Go builds faster?
Most slow Go builds have one cause: the go command starts with an empty cache. A fast Go build keeps the build cache (GOCACHE) and the module cache (GOMODCACHE) between runs. It also keeps the inputs of each cache key stable, lets go test reuse passed results, and builds cgo code with a C compiler. On a laptop, Go keeps its caches for you. On a CI runner that starts empty, you must restore both caches yourself. Dagger meets the same requirements in one place. The dagger.io/go module mounts both Go caches as persistent volumes and runs the tests of each package as a separate check, in parallel, locally and in CI.
What makes a Go build slow
A fast Go build meets these requirements. The go command documentation is the source for each one.
- Keep the build cache. The
gocommand caches build outputs inGOCACHE, which is~/.cache/go-buildon Linux. Since Go 1.20, Go does not ship precompiled standard library packages. They are “built as needed and cached in the build cache”, so an emptyGOCACHEalso compilesfmt,net/http, and the rest of the standard library again. - Keep the module cache. The
gocommand stores downloaded modules inGOMODCACHE, which defaults toGOPATH/pkg/mod. An empty module cache downloads every dependency again. - Keep cache keys stable. The build cache “correctly accounts for changes to Go source files, compilers, compiler options, and so on”. A change to the Go version,
CGO_ENABLED,-race, or-tagsgives new cache keys, so the build compiles again. - Let
go testreuse results.go testcaches passed results only in package list mode, and only with the cacheable flags. - Handle cgo on purpose. cgo needs a C compiler, and “the build cache does not detect changes to C libraries imported with cgo”.
Measure before you change anything
Time the build first, then ask the go command why it rebuilt. Use these tools from the go help cache and go help build docs:
time go test ./...gives the wall time. A second run shows how much the cache saves.go build -xprints each command that runs. A warm build with no change prints no compile commands.GODEBUG=gocachehash=1prints the inputs of each cache key. Compare the output of two runs to find the input that changed.GODEBUG=gocachetest=1prints whygo testdid or did not reuse a cached result.GODEBUG=gocacheverify=1ignores the cache, rebuilds everything, and checks that the results match the cache entries.go build -arebuilds all packages, also packages that are up to date.
This guide used a project with six packages that import the AWS SDK for Go v2 and k8s.io/client-go, with 167 lines in go.sum. In a golang:1.26-bookworm container with empty caches, each command took this long:
| Command | Time |
|---|---|
go mod download |
4.9 s |
go test ./..., first run |
14.0 s |
go test ./..., no change |
0.18 s, every package (cached) |
go test -count=1 ./... |
0.8 s |
go test ./... after a one-line change in one package |
0.8 s |
These numbers come from one run on one machine. In this project, the first download and compile took most of the time, so keep the caches first.
Keep GOCACHE and GOMODCACHE between runs
The build cache and the module cache help only if they exist at the next run. On a developer machine, they stay in the home directory. The go command “periodically deletes cached data that has not been used recently”, so you do not need to clean the cache. Use go clean -cache only to debug the cache.
On a CI runner that starts empty, save both directories at the end of a job and restore them at the start of the next job. Get the paths with go env GOCACHE GOMODCACHE. On GitHub Actions, actions/setup-go caches modules and build outputs by default. By default, it makes the cache key from go.mod, and the cache-dependency-path input changes the file it uses. For full control, use actions/cache with your own key.
Keep cache keys stable
Each change to a compiler option gives a new set of cache keys. In this guide’s project, a warm build took 0.8 s. The next build, with CGO_ENABLED=0, took 8.7 s, because the go command compiled each package again for the new keys. Use the same Go version, GOFLAGS, build tags, and CGO_ENABLED value everywhere that shares a cache. -race and -cover are build flags too, so their builds use separate cache entries.
A binary build has one more step after the compile: the link. In this guide’s project, a one-line change to the main package rebuilt the binary in 0.4 s. With an empty cache, the same build took 5.8 s. The go command compiled only the changed package, then linked the binary again.
Let go test reuse passed results
go test caches a passed result only in package list mode. The go help test docs define the two modes:
- Local directory mode is
go testwith no package arguments. “In this mode, caching (discussed below) is disabled.” - Package list mode is
go testwith package arguments, such asgo test ./...orgo test .. A passed package prints(cached)the next time.
go test reuses a result only when the flags “come entirely from a restricted set of ‘cacheable’ test flags”: -benchtime, -coverprofile, -cpu, -failfast, -fullpath, -list, -outputdir, -parallel, -run, -short, -skip, -timeout, and -v. Any other flag turns off the test cache. “The idiomatic way to disable test caching explicitly is to use -count=1.” Search your Makefile and CI scripts for -count=1. Keep it only on tests that must run each time, such as tests against a live service.
Tests that open files in the module or read environment variables “only match future runs in which the files and environment variables are unchanged”. Run GODEBUG=gocachetest=1 go test ./pkg to see which input made a test run again. Run go clean -testcache to remove the cached test results and keep the build cache.
Handle cgo on purpose
cgo packages need a C compiler, and the build cache does not track the C libraries they use. If the C libraries on the system change, use go build -a or clean the cache, as go help cache says.
Since Go 1.20, CGO_ENABLED defaults to 0 when the go command cannot find a C compiler. The golang:1.26-alpine image has no gcc, so a file with import "C" drops out of the build there. In this guide’s run, the error named a missing symbol, not cgo. If the project does not need cgo, set CGO_ENABLED=0 everywhere, so that the cache keys stay the same. If it needs cgo, build in an image that has a C compiler, such as golang:1.26-bookworm.
Where the hand-built cache strains in CI
The requirements stay the same as a project grows, but the CI configuration that meets them gets harder to maintain:
- The cache goes stale. “You cannot change the contents of an existing cache,” as the GitHub Actions cache reference says. With a key on
go.mod, the saved build cache keeps old compiled packages untilgo.modchanges. A key on the source files gives a new cache for each commit, and needsrestore-keysas a fallback. - The settings live in many places. The Go version,
CGO_ENABLED, and build tags are in the Makefile, the workflow, and the shell of each developer. When one copy changes and the others do not, the cache keys no longer match.
Keep the Go caches warm with Dagger
Dagger runs the same go commands in containers on the Dagger Engine, which runs pipelines and stores the cache. A module is a package of functions that build and test a project. The official dagger.io/go module finds each go.mod. It runs the tests of each package as a check, a function that passes or fails. Complete the Quickstart first, in a Git repository.
Install the Go module:
dagger module install dagger.io/go
Installed module "go" in /work/dagger.toml
The command adds the module to dagger.toml. It also writes dagger.lock, which pins the module to an exact version. Later Dagger commands add each image that they use to dagger.lock. Commit both files. Then run the checks:
dagger check
Keep the build cache and the module cache. The Go module mounts two cache volumes, directories whose contents persist across Dagger sessions. One is at /go/pkg/mod, the module cache. The other is at /root/.cache/go-build, the build cache. You do not write a save step or a restore step.
Keep cache keys stable. Each Go module runs in golang:<version>-alpine, for the version that its own go.mod asks for. The version, goflags, and buildFlags settings in dagger.toml apply to every run, on every machine (Go module reference).
Let go test reuse results. Dagger caches the result of a check by the files that the check saw (Daggerize a Go Project). Each package is a separate check that runs go test on that package, and dagger check runs all checks in parallel (Checking your code). The second run with no changes printed this:
== CHECKS == ✔ 7 passed
✔ dag://?check=go/packages/generate/stale 1.1s OK
✔ dag://?check=test&go-package=cart 1.0s OK
✔ dag://?check=test&go-package=inventory 1.0s OK
✔ dag://?check=test&go-package=orders 1.0s OK
✔ dag://?check=test&go-package=payments 1.0s OK
✔ dag://?check=test&go-package=shipping 1.0s OK
✔ dag://?check=test&go-package=users 1.0s OK
The same six-package project gave these times on one machine:
dagger check run |
Time |
|---|---|
| First run, new dependencies | 49.1 s |
| No change | 4.7 s |
One-line change in orders |
5.6 s |
Change to README.md only |
3.8 s |
Dagger keeps the caches on each engine, so a CI run that uses the same engine starts warm. To test only the package you changed, select it:
dagger check --go-package=orders
Run cgo packages in an image with a C compiler
If no package uses cgo, skip this section. The default Alpine image has no gcc, so a cgo package does not build there. For a test of a cgo function, dagger check printed this error:
┃ # example.com/shop/hash [example.com/shop/hash.test]
┃ hash/hash_test.go:6:5: undefined: Abs
┃ FAIL example.com/shop/hash [build failed]
Give the Go module a container with a C compiler through its base setting. Install the Dang SDK. Then create the project’s dev module in Dang, the native language of Dagger modules:
dagger module install dagger.io/sdk/dang
dagger module init dang
dagger module init creates .dagger/modules/<project>-dev. Replace the contents of .dagger/modules/<project>-dev/main.dang with this function. ProjectDev stands in for the type name that dagger module init generated, so keep your own type name:
type ProjectDev {
"""
The container the Go module runs tests in: Debian, with gcc for cgo.
"""
testRuntime: Container! {
container.from("golang:1.26-bookworm")
}
}
Connect the function to the Go module:
dagger module settings go base test-runtime
dagger.toml now holds base = "dag://<project>-dev/test-runtime" under [modules.go.settings]. The image now sets the Go version, so the module ignores its version setting. This is the same fix as the hand-built version: an image with a C compiler. Run dagger check again, and the cgo test passes.
Run the same checks locally and in CI
Cloud Checks run dagger check on Dagger’s Cloud Engines for each push and pull request to a GitHub repository, with no CI workflow file. Connect the repository:
dagger cloud checks on
The checks in CI are the checks that dagger check runs on your machine, with the same image, the same GOFLAGS, and the same cache keys.
Bottom line: To make Go builds faster, keep GOCACHE and GOMODCACHE between runs, and use the same compiler options and Go version everywhere. Run go test in package list mode without -count=1, and build cgo code in an image with a C compiler. The dagger.io/go module keeps both Go caches in persistent volumes and runs the tests of each package in parallel, locally and with Cloud Checks.
Related guides
- How do I run Go tests, lint, and go generate the same way locally and in CI?: the full Go module setup, with lint, generators, and test services.
- How do I run golangci-lint the same way locally and in CI?: Go linting as a Dagger check.
- Can Dagger lower CI costs?: how cache reuse changes what CI costs.