Skip to content
compiler.dev

Speed Up Go in GitHub Actions

Last updated

To speed up Go in GitHub Actions, use actions/setup-go with its built-in cache, keep GOCACHE warm so unchanged packages are not recompiled, run packages in parallel with go test -p, and run the race detector in a separate job. Go compiles fast, so most wasted time is re-downloading modules and rebuilding what has not changed.

This guide is the Go version of our general guide to faster GitHub Actions.

Module and build cache

actions/setup-go caches by default. Its README says the cache input defaults to true, and that the cache key is based on your dependency file (go.sum or go.mod) unless you set cache-dependency-path to other files (it accepts globs). The cache stores the module download cache and the Go build cache, so both go mod download and compiled packages come back on a hit.

- uses: actions/checkout@v7
- uses: actions/setup-go@v7
  with:
    go-version-file: go.mod
    cache-dependency-path: |
      go.sum
      tools/go.sum

go-version-file reads the version from go.mod, go.work or .go-version, so CI and your laptop stay in sync. If you set go-version as well, that one wins.

Cache entries are immutable in GitHub Actions, and this key only changes when go.sum changes. That means source edits do not update the build cache after the first save for a given go.sum. For a big repo that is a real limit: the restored build cache gets stale over weeks. A fix is to use cache: false and manage ~/.cache/go-build yourself with a key that includes the commit and a restore-keys prefix:

- uses: actions/setup-go@v7
  with:
    go-version-file: go.mod
    cache: false
- uses: actions/cache@v6
  with:
    path: |
      ~/go/pkg/mod
      ~/.cache/go-build
    key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}-${{ github.sha }}
    restore-keys: |
      go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}-
      go-${{ runner.os }}-

That writes a new entry per commit, so watch your quota; see cache limits. Save only on main with actions/cache/save if storage is tight, and restore everywhere else.

Test parallelism: -p and -parallel

Two different knobs:

  • go test -p N sets how many packages are tested at the same time. It defaults to GOMAXPROCS, which is the CPU count. Packages run as separate processes.
  • go test -parallel N sets how many tests inside one package may run at once, but only tests that call t.Parallel(). It also defaults to GOMAXPROCS.

So adding cores speeds up a Go suite automatically if you have many packages. If most of your tests sit in one big package without t.Parallel(), extra cores do nothing for that package. Add t.Parallel() to independent tests, and never to tests that share a database row or a global.

Useful flags in CI:

go test ./... -count=1 -shuffle=on -timeout=10m

-count=1 disables Go's test result cache, which you usually want in CI for tests that touch the outside world. If your tests are pure, leaving the cache on lets a warm GOCACHE skip unchanged packages entirely, which is a large saving.

The cost of -race

The race detector instruments memory accesses. The Go race detector docs say it can increase execution time by 2 to 20 times and memory use by 5 to 10 times, so a test suite with -race is often several times slower. Do not drop it, since it finds real bugs, but do not run it three times in a matrix either.

A common setup is a fast job without -race for every push, and a -race job that runs in parallel and is required for merge:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
      - run: go test ./... -count=1
  race:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
      - run: go test ./... -race -count=1

Both jobs finish at the pace of the slower one, but you see fast feedback in the logs of the first. Race builds need cgo, so they use a different compile than normal builds and a separate build cache; the cache from the non-race job will not help it.

Lint with golangci-lint

golangci-lint can take longer than the tests on large repos. Use the official golangci/golangci-lint-action, check its README for current inputs and version, and run it as its own job so it overlaps with tests. Its action caches analysis results by default.

Build once, ship once

For Docker images, compile with a cache mount in the Dockerfile (RUN --mount=type=cache,target=/root/.cache/go-build go build ...) and build with BuildKit layer caching. See Docker layer caching in CI. Cross-compiling for several targets? Use a matrix of GOOS/GOARCH instead of emulation, since Go cross-compiles natively.

What more CPU cores buy you

Go uses all cores for both compiling and testing, so it is one of the better fits for a bigger runner. Our benchmarks include a React + Go + Postgres stack with go test -race: 6m13s on a GitHub-hosted runner and 2m42s on compiler.dev (2.3x), a full CI run with 10 test shards and a real database, measured on 8 October 2026. A Next.js + Go + Kafka + Redis stack went from 1m16s to 0m44s (1.7x). Those are whole pipelines, so your ratio depends on how much of your run is Go compile and test time.

Switching a job to compiler.dev means changing to runs-on: [self-hosted, Linux, compiler-ci]. Machine sizes and prices are on the pricing page, and the runner size picker helps choose one. See also the Go template and test sharding.

FAQ

Does setup-go cache Go modules automatically?

Yes. The cache input defaults to true and caches the module and build caches, keyed on your dependency file unless you set cache-dependency-path.

What is the difference between go test -p and -parallel?

-p is how many packages test at once. -parallel is how many t.Parallel() tests run at once inside a package. Both default to the CPU count.

How much slower is go test -race?

The Go docs say 2 to 20 times slower. In practice a suite with I/O waits sees less. Run it in its own job.

Should I use -count=1 in CI?

Use it when tests touch databases or the network, so cached results are not reused. For pure unit tests, leave the cache on.

Run this Go build on compiler.dev 200 free minutes

Start free