How Do I Cache Go Modules in GitHub Actions?
Cache ~/go/pkg/mod under a key hashed from go.sum, and cache ~/.cache/go-build under a key that rolls every commit with restore-keys, as two steps.
Cache two directories under two different keys: the module cache (GOMODCACHE, ~/go/pkg/mod by default) keyed on hashFiles('**/go.sum'), and the build cache (GOCACHE, ~/.cache/go-build on Linux) keyed on something that changes every commit, with restore-keys so a miss falls back to the nearest previous entry. One key cannot serve both, because the module cache only changes when a dependency version changes while the build cache changes whenever you edit source.
On WarpBuild runners, WarpBuilds/cache is a drop-in replacement for actions/cache@v4 and is enabled by default on Linux runners (caching documentation).
Answer
The Go toolchain keeps two caches, and go env GOMODCACHE GOCACHE prints where both live on any machine.
| Directory | Default path | What it holds | What changes it | Key shape |
|---|---|---|---|---|
| GOMODCACHE | ~/go/pkg/mod | Module zips and extracted dependency source, verified against go.sum | A dependency version change in the module graph | ${{ runner.os }}-gomod-${{ hashFiles('**/go.sum') }} |
| GOCACHE | ~/.cache/go-build on Linux, ~/Library/Caches/go-build on macOS | Compiled package artifacts and test results, including the standard library packages your code imports | Any source edit, a Go version bump, a build tag change, or a flag change such as -race | ${{ runner.os }}-gobuild-${{ hashFiles('**/go.sum') }}-${{ github.sha }} plus restore-keys |
The two rows want opposite behavior from a cache action. An exact hit on the module cache is the goal, because an exact hit means the post-run save is skipped and nothing is re-uploaded. An exact hit on the build cache is close to impossible once source is moving, so that entry is designed around a prefix restore: the key carries github.sha, which never repeats, and restore-keys walks back to the most recent entry for the same go.sum.
Separate steps, separate keys:
name: go-ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
cache: false
- name: Restore module cache
id: gomod
uses: WarpBuilds/cache@v1
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-gomod-${{ hashFiles('**/go.sum') }}
- name: Restore build cache
uses: WarpBuilds/cache@v1
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-gobuild-${{ hashFiles('**/go.sum') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-gobuild-${{ hashFiles('**/go.sum') }}-
${{ runner.os }}-gobuild-
- name: Download modules
if: steps.gomod.outputs.cache-hit != 'true'
run: go mod download
- run: go build ./...
- run: go test ./...cache: false on the setup step is the important line. Without it the setup action writes its own entry covering the same two directories, and the job pays for both sets of transfers.
Detail
Why the module cache and the build cache need different keys
go.sum lists the version and checksum of every module in the build graph, direct and indirect (Go modules reference), so hashing it gives the module cache a key that rolls at exactly the moments its contents change. Files in the module cache are read-only by design (module cache reference), so a step that wants to delete or rewrite that tree needs chmod -R u+w first, or go clean -modcache.
The build cache is keyed by the content of everything that went into each compiled package: source, compiler version, build flags, and the inputs of every dependency (build and test caching). One character of source change invalidates that package and everything downstream of it, and leaves the rest of the entries usable. That is why the prefix restore matters more than the exact key: yesterday's build cache still answers for every package the commit did not touch. Since Go 1.20 the distribution stops shipping precompiled archives for the standard library (Go 1.20 release notes), so a cold GOCACHE also means recompiling the standard library packages your code imports.
Give the build cache a key hashed only from go.sum and it freezes: the first run after a dependency bump writes compiled artifacts, every later run gets an exact hit, and the save step never runs again until the next bump. The cache stops tracking your code within a day.
How the setup action's built-in cache overlaps a manual step
actions/setup-go enables caching by default and stores both GOMODCACHE and GOCACHE in one entry, keyed from the Go version and the hash of go.sum. That is a reasonable default for a small service, and it inherits the freeze described above for the build cache half.
WarpBuilds/setup-go is the drop-in that routes the same caching through WarpBuild Cache. Caching is on by default and keyed on the hash of go.mod, and cache-dependency-path points it at **/go.sum for a tighter key (setup actions documentation):
- uses: WarpBuilds/setup-go@v6
with:
go-version: "1.24"
cache-dependency-path: "**/go.sum"Which one to keep comes down to where the job time goes. Keep the setup action alone when a cold build cache costs seconds, because one step and one entry is less to maintain. Take manual control with two steps when compile time dominates the job, when the repository is a monorepo whose go.sum sits somewhere other than the root, or when a matrix leg needs its own key. Running both at once is the configuration to avoid.
Cache scope, version, and expiry decide whether a restore matches
Three rules from the caching documentation determine whether the entry you saved is the entry you get back.
Entries are scoped to key, version, and branch, so a cache written on a feature branch does not serve main. Seed on main and let branch jobs restore through restore-keys.
Cache version is a hash of the compression tool and the list of paths in the step, so a cache saved by a step listing two paths cannot be restored by a step listing one. Splitting a combined Go cache into two steps starts both entries cold on the first run, and the old combined entry ages out on its own.
Entries expire 7 days after last use. A repository with daily merges stays warm; a branch nobody touches stops occupying storage without any cleanup step.
What the two entries cost
Cache metering rates come from the pricing page and the caching documentation, checked on 2026-08-13: storage is $0.20 per GB-month, and each write, restore, or list operation is $0.0001. Worked example for a mid-sized Go service:
| Line item | Quantity | Rate | Monthly |
|---|---|---|---|
| Module cache storage | 1.2 GB | $0.20 per GB-month | $0.24 |
| Build cache storage | 2.5 GB | $0.20 per GB-month | $0.50 |
| Cache operations, 2,000 jobs at 4 operations each | 8,000 | $0.0001 each | $0.80 |
| Total | $1.54 |
On BYOC runners, cache storage and cache operations are free, because the storage sits in your own cloud account. Pricing is otherwise purely usage based.
Where this configuration stops applying
WarpBuild Cache is not supported on Windows runners (caching documentation), so a Windows Go job keeps actions/cache and GitHub Actions Cache with the same two-key layout.
When the restore transfer itself becomes the cost, the file-level cache is the wrong tool. A snapshot runner boots a job from a machine image that already has both Go caches populated. The guide to persistent caches for GitHub Actions runs works through the arithmetic that picks between the two mechanisms.
Related Questions
Should a Go cache key hash go.mod or go.sum?
Hash go.sum. It records the exact version and checksum of every module in the build graph, including indirect dependencies, so the key rolls when the downloaded bytes change and stays put when they do not. go.mod can stay identical while a transitive dependency resolves to a new version. The cache key glossary entry covers key composition and prefix matching in more depth.
Does actions/setup-go already cache Go modules for me?
Yes, and it caches the build cache in the same entry under one key derived from the Go version and the hash of go.sum. On an exact key hit the post-run save is skipped, so the compiled artifacts in that entry stay frozen at the state they had when go.sum last changed. The setup actions documentation lists the cache inputs for the WarpBuild fork of each setup action.
Can I keep the setup action cache and a manual cache step together?
Pick one owner per directory. Running both means two entries covering the same paths, two uploads, and two downloads per job. Set cache: false on the setup action when you want manual control of the keys, or drop the manual steps and let the setup action own both directories.
Do warm caches change which runner size I should pick?
Yes. A cold job spends its minutes on downloads and recompiles, which barely use extra cores, so a large runner mostly idles. Once both caches restore, compile and test parallelism becomes the limit and core count starts paying for itself. The runner right-sizing guide walks the measurement, and the Go on GitHub Actions page covers the 4, 8, and 16 vCPU decision for go build and go test -race specifically.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.