Go's Build Cache: Content Hashes, Action IDs and Why Rebuilds Are Instant
The go command keys every build step on a hash of its inputs, stores outputs under a hash of their contents, and never re-runs the compiler for work it has already done.
TL;DR:
go buildnever asks “has this file changed?”. It asks “is there an entry in$GOCACHEwhose action ID — a hash of every input that could affect the output — matches this action?”. If yes, the compiler never runs. If no, it runs once, and the output is filed under its content ID, a hash of the output bytes. Two hashes, two levels, instant rebuilds.
Why is go build so fast the second time? Not because Go has an incremental
compiler. It does not. Go’s build system is a content addressable cache, and
on a cache hit the compiler process never starts.
Getting there takes two ideas that are easy to get subtly wrong: an action ID (a hash of the inputs) and an output ID (a hash of the results). They are different hashes, they live in different places, and the distinction is what makes the whole thing work.
This is a walk through how it works, based on reading the Go 1.28 development tree.
Two hashes, two jobs
The vocabulary lives at the top of src/cmd/go/internal/cache/cache.go:
// src/cmd/go/internal/cache/cache.go:28
// An ActionID is a cache action key, the hash of a complete description of a
// repeatable computation (command line, environment variables,
// input file contents, executable contents).
type ActionID [HashSize]byte
// An OutputID is a cache output key, the hash of an output of a computation.
type OutputID [HashSize]byteAn ActionID hashes a description of the computation: the command line,
the environment, the input file contents, the compiler binary. An OutputID
hashes the output itself. They are separate types so the compiler cannot
use one where the other belongs. HashSize is 32 bytes:
// src/cmd/go/internal/cache/hash.go:21
// HashSize is the number of bytes in a hash.
const HashSize = 32Why two levels? Because of sharing. The standard library’s fmt package
is compiled once, and its export data is an input to every importer.
| Level | Key | Value | Stored as |
|---|---|---|---|
| Action index | ActionID | OutputID | one text file per action, suffixed -a |
| Content store | OutputID | the output bytes | one file per distinct output, suffixed -d |
The first level is an index; the second is content addressing, shared by every action producing the same bytes.
Where the cache lives
The cache directory is $GOCACHE, and if that is unset it defaults to the
user cache directory plus go-build:
// src/cmd/go/internal/cache/default.go:84
if err != nil {
defaultDir = "off"
defaultDirErr = fmt.Errorf("GOCACHE is not defined and %v", err)
} else {
defaultDir = filepath.Join(dir, "go-build")
}
newDir := cfg.Getenv("GOCACHE")
if newDir != "" {Note the strictness: GOCACHE must be an absolute path. A relative path
makes the cache depend on the working directory — a cache that silently never
hits.
On disk, the cache is 256 subdirectories named by the first byte of the hash.
The naming convention encodes the two levels in the filename — an a suffix
for the index entry, a d suffix for the data file:
// src/cmd/go/internal/cache/cache.go:116
// fileName returns the name of the file corresponding to the given id.
func (c *DiskCache) fileName(id [HashSize]byte, key string) string {
return filepath.Join(c.dir, fmt.Sprintf("%02x", id[0]), fmt.Sprintf("%x", id)+"-"+key)
}So a cache directory looks like this:
~/.cache/go-build/
├── README
├── trim.txt
├── 00/
│ ├── 0f3a...c4d1-a <- "action 0f3a...c4d1 produced output 91bc...7e02"
│ └── 91bc...7e02-d <- the actual .a file
└── ...The salt
One twist in the hashing is worth understanding, because it explains why your cache directory has more entries than you expect.
// src/cmd/go/internal/cache/hash.go:33
// created by NewHash. Using the Go version makes sure that different
// versions of the go command (or even different Git commits during
// work on the development branch) do not address the same cache
// entries, so that a bug in one version does not affect the execution
// of other versions. This salt will result in additional ActionID files
// in the cache, but not additional copies of the large output files,
// which are still addressed by unsalted SHA256.The salt is the local toolchain version, and it applies to the action hashes only. A Go 1.28 build never collides with a Go 1.29 build’s cache entry, so a compiler bug fix cannot poison your cache. The data files are addressed by unsalted SHA-256, so two toolchains that produce byte-identical output share one copy.
Computing the action ID
The action ID for a compile step is computed in
src/cmd/go/internal/work/exec.go:
// src/cmd/go/internal/work/exec.go:264
// buildActionID computes the action ID for a build action.
func (b *Builder) buildActionID(a *Action) cache.ActionID {
p := a.Package
h := cache.NewHash("build " + p.ImportPath)
// Configuration independent of compiler toolchain.
// Note: buildmode has already been accounted for in buildGcflags
// and should not be inserted explicitly. Most buildmodes use the
// same compiler settings and can reuse each other's results.
// If not, the reason is already recorded in buildGcflags.
fmt.Fprintf(h, "compile\n")
b.addPackageOrigin(h, p)
if p.Module != nil {The hash is a textual hash of a description, not of the input bytes: a
sequence of fmt.Fprintf calls into the hash. Each line is a labelled fact
— target platform, compiler flags, toolchain ID, file hashes, dependency
export hashes.
The toolchain ID matters: it is how a fixed compiler bug invalidates every cached package built with the old compiler:
// src/cmd/go/internal/work/exec.go:307
case "gc":
fmt.Fprintf(h, "compile %s %q %q\n", b.toolID("compile"), forcedGcflags, p.Internal.Gcflags)
if len(p.SFiles) > 0 {
fmt.Fprintf(h, "asm %q %q %q\n", b.toolID("asm"), forcedAsmflags, p.Internal.Asmflags)
}toolID asks the tool for its own build ID, via -V=full. Then the input
files themselves, hashed by content:
// src/cmd/go/internal/work/exec.go:371
for _, file := range inputFiles {
fmt.Fprintf(h, "file %s %s\n", file, b.fileHash(filepath.Join(p.Dir, file)))
}
for _, a1 := range a.Deps {A dependency contributes only its export data content ID, not its whole object file — because the compiler only reads export data.
The lookup
The index entry is a small fixed-width text file, so it is cheap to parse and
human-inspectable with cat:
// src/cmd/go/internal/cache/cache.go:139
// action entry file is "v1 <hex id> <hex out> <decimal size space-padded to 20 bytes> <unixnano space-padded to 20 bytes>\n"
hexSize = HashSize * 2
entrySize = 2 + 1 + hexSize + 1 + hexSize + 1 + 20 + 1 + 20 + 1
)A version tag, the action ID, the output ID, the size and a timestamp — all fixed width, space-padded, newline terminated. The lookup opens and reads it:
// src/cmd/go/internal/cache/cache.go:201
func (c *DiskCache) get(id ActionID) (Entry, error) {
missing := func(reason error) (Entry, error) {
return Entry{}, &entryNotFoundError{Err: reason}
}
f, err := os.Open(c.fileName(id, "a"))
if err != nil {The store side computes the output ID from the bytes and writes the index entry:
// src/cmd/go/internal/cache/cache.go:564
func (c *DiskCache) put(id ActionID, executableName string, file io.ReadSeeker, allowVerify bool) (OutputID, int64, error) {
// Compute output ID.
h := sha256.New()
if _, err := file.Seek(0, 0); err != nil {
return OutputID{}, 0, err
}
size, err := io.Copy(h, file)
if err != nil {
return OutputID{}, 0, errNote the output ID is unsalted SHA-256 of the bytes. The salt applies only to the action index, which is why the same compiled package is stored once no matter how many action IDs point at it.
Build IDs: the same idea, embedded in the artifacts
Build IDs are a related mechanism that ends up inside the .a files and
binaries. They answer a slightly different question: is this already-installed
artifact up to date, without re-running the compiler?
// src/cmd/go/internal/work/buildid.go:28
// Build IDs
//
// Go packages and binaries are stamped with build IDs that record both
// the action ID, which is a hash of the inputs to the action that produced
// the packages or binary, and their content IDs which are hashes of the
// action outputs. The content IDs are hashes of the export and object files
// in the case of gc package builds, or of the entire archive or binary, in the case
// of gccgo builds or link actions. These hashes are the same
// used by the build artifact cache (see cmd/go/internal/cache), but
// truncated when stored in packages and binaries, as the full length is not
// needed and is a bit unwieldy. The precise forms are
//Read the two forms. A package archive carries three components; a binary carries four, because the binary is the only artifact we install:
// src/cmd/go/internal/work/buildid.go:74
// The “one-element cache” purpose is a bit more complex for installed
// binaries. For a binary, like cmd/gofmt, there are two steps: compile
// cmd/gofmt/*.go into main.a, and then link main.a into the gofmt binary.
// We do not install gofmt's main.a, only the gofmt binary. Being able to
// decide that the gofmt binary is up-to-date means computing the action IDThe hashes are truncated on the way in — 120 bits of SHA-256, encoded in base64 into 20 bytes:
// src/cmd/internal/buildid/buildid.go:320
// HashToString converts the hash h to a string to be recorded
// in package archives and binaries as part of the build ID.
// We use the first 120 bits of the hash (5 chunks of 24 bits each) and encode
// it in base64, resulting in a 20-byte string. Because this is only used for
// detecting the need to rebuild installed files (not for lookups
// in the object file cache), 120 bits are sufficient to drive the
// probability of a false "do not need to rebuild" decision to effectively zero.
// We embed two different hashes in archives and four in binaries,The hit path
Here is the function that decides whether to run the compiler at all:
// src/cmd/go/internal/work/buildid.go:469
func (b *Builder) useCache(a *Action, actionHash cache.ActionID, target string, printOutput bool) (ok bool) {
// The second half of the build ID here is a placeholder for the content hash.
// It's important that the overall buildID be unlikely verging on impossible
// to appear in the output by chance, but that should be taken care of by
// the actionID half; if it also appeared in the input that would be like anThen the actual lookup, which pulls the cached file out and reads the build ID back out of it:
// src/cmd/go/internal/work/buildid.go:601
// Check to see if the action output is cached.
if file, _, err := cache.GetFile(c, actionHash); err == nil {
if a.Mode == "preprocess PGO profile" {There is also a “one-element cache” trick before all of this: if the target file exists on disk and its embedded build ID starts with the action ID we just computed, the cache is not even consulted.
go build prints no “cache hit” line, but go test prints (cached) where
the elapsed time would otherwise go:
// src/cmd/go/internal/test/test.go:2016
c.buf = new(bytes.Buffer)
c.buf.Write(data[:j])
c.buf.WriteString("(cached)")
for j < len(data) && ('0' <= data[j] && data[j] <= '9' || data[j] == '.' || data[j] == 's') {GODEBUG=gocachehash=1 prints every hash as it is computed, and
GODEBUG=gocacheverify=1 makes the cache always miss on read, then verify on
write that the recomputed output matches what was already there.
Trimming
A cache that only ever grows is a liability. The go command trims it automatically, once a day, deleting anything not used in five days:
// src/cmd/go/internal/cache/cache.go:345
// Go developers found that essentially all reuse of cached entries happened
// within 5 days of the previous reuse. See golang.org/issue/22990.
const (
mtimeInterval = 1 * time.Hour
trimInterval = 24 * time.Hour
trimLimit = 5 * 24 * time.HourFive days is not arbitrary — it came from a month of telemetry from Go
developers. The trim is guarded by a timestamp file so twenty concurrent
go builds do not all scan the cache at once:
// src/cmd/go/internal/cache/cache.go:377
// Trim removes old cache entries that are likely not to be reused.
func (c *DiskCache) Trim() error {
now := c.now()
// We maintain in dir/trim.txt the time of the last completed cache trim.
// If the cache has been trimmed recently enough, do nothing.go clean -cache throws the whole thing away, but deliberately keeps the
top-level directory and its permissions:
// src/cmd/go/internal/clean/clean.go:159
if cleanCache {
dir, _, err := cache.DefaultDir()
if err != nil {
base.Fatal(err)
}
if dir != "off" {
// Remove the cache subdirectories but not the top cache directory.
// The top cache directory may have been created with special permissionsNot to be confused with the module cache
There is a second cache in the go command, and it is a different thing
entirely. $GOMODCACHE stores downloaded module source, keyed by module
path and version:
// src/cmd/go/internal/modfetch/cache.go:37
func cacheDir(ctx context.Context, path string) (string, error) {
if err := checkCacheDir(ctx); err != nil {
return "", err
}
enc, err := module.EscapePath(path)
if err != nil {
return "", err
}
return filepath.Join(cfg.GOMODCACHE, "cache/download", enc, "/@v"), nil
}The build cache is content-addressed and self-managing; the module cache is
a plain directory tree that go mod download fills and go clean -modcache
empties. Unrelated systems sharing a word.
Trade-offs
What the cache buys: the second go build after a no-op edit is
essentially free — a warm go build ./... runs no compiler at all.
What it costs:
- Disk. The five-day trim bounds it in practice, but a busy CI machine can reach tens of gigabytes.
- Hashing is not free. Every input file is SHA-256’d on every build, so the file hash is memoised per-process.
- The hash must capture everything that affects the output. Miss one —
an environment variable, a
#definein a C header — and you get a stale artifact that is very hard to diagnose. - The cache is per-toolchain-version. Upgrading Go invalidates every action ID, though the output files are shared.
Caveats
The cache is keyed on content, not on mtime. Touching a file does not invalidate anything, because the mtime is not in the hash.
go build -a bypasses the cache entirely. It makes useCache return
false without even consulting the cache.
A corrupt cache entry is a miss, not a crash. The size check in
GetFile and the format validation in get turn it into a miss.
GODEBUG=gocacheverify=1 is the tool for “I do not believe this”. It
forces every read to miss, then checks the recomputed output against the
stored one, panicking with a diff if they differ.
GOCACHE=off is not supported. The cache has been mandatory since Go
1.12, and the go command will refuse to start.
Wrapping up
The one thing to remember: the build cache is content-addressed at two levels. The action ID hashes the inputs; the output ID hashes the results. A cache hit means the compiler never runs.
The part people get wrong: the action ID must include everything that can change the output — compiler flags, toolchain ID, environment, file contents, dependency export hashes. A missing input is not a stale cache entry; it is a wrong binary that reproduces forever.
What to go do about it: run go clean -cache and time a cold
go build ./... against a warm one. The difference is the entire compiler.
Further reading
src/cmd/go/internal/cache/cache.go—ActionID,OutputID,DiskCache,get,put,putIndexEntry,OutputFile,Trim.src/cmd/go/internal/cache/hash.go—HashSize,hashSalt,NewHash,FileHash,Subkey.src/cmd/go/internal/cache/default.go—Default,initDefaultCache,DefaultDir, and theGOCACHEPROGhook.src/cmd/go/internal/work/exec.go—buildActionID,linkActionID,toolID, and the cgo invocation.src/cmd/go/internal/work/buildid.go— the “Build IDs” design note,useCacheandupdateBuildID.src/cmd/go/internal/work/action.go— theActionstruct, with itsactionIDandbuildIDfields.src/cmd/internal/buildid/buildid.go—ReadFileandHashToString.src/cmd/buildid/buildid.go— thego tool buildidcommand.src/cmd/go/internal/clean/clean.go—go clean -cacheand-testcache.src/cmd/go/internal/modfetch/cache.go— the module download cache, which is a different thing entirely.