skip to content

Running Under -race

The -race build instruments every memory access, so the suite runs several times slower and reports only the races the run actually executed. That is why it belongs in CI.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

What does go test -race do, and what happens to the run when it detects a race?

level: juniorimportance: must knowfreq 70%

answer

  1. a build flag, not a linter
  2. the binary is rebuilt instrumented
  3. only conflicts that actually execute
  4. two stacks, then a FAIL line
  5. the non-zero exit is what CI sees

basics

~20 s

go test -race compiles the tests with the race detector, which watches memory accesses as they run. A conflict prints a WARNING: DATA RACE report naming both accesses with their goroutine stacks, fails that test, and makes go test exit non-zero.

solid answer

~50 s

`go test -race` rebuilds the package under test and its dependencies with instrumentation, so every memory access and every synchronisation operation is recorded while the tests execute. It is a dynamic check: it reports a conflict only when both accesses actually run in that particular run, so a race on a path the suite never drives stays invisible. When it fires, the runtime prints a `WARNING: DATA RACE` block with both accesses and their goroutine stacks; the `testing` package notices and fails the running test with `race detected during execution of test`. By default the run keeps going and reports further races, printing `Found N data race(s)` at exit. Either way `go test` prints FAIL and exits non-zero — that non-zero exit is the whole reason the flag is usable as a CI gate rather than just a debugging aid.

code

text · 14 lines
text
$ go test -race ./cache
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 9:
  example/cache.(*Store).Set()
      /src/cache/store.go:41 +0x64

Previous read at 0x00c0000b4010 by goroutine 8:
  example/cache.(*Store).Get()
      /src/cache/store.go:29 +0x3c
==================
--- FAIL: TestStoreConcurrent (0.01s)
    race detected during execution of test
FAIL

go deeper

for a junior

Be ready to say that -race is passed to go test, that it rebuilds the tests with instrumentation, that the report names both conflicting accesses, and that the run fails. Mention running it locally before pushing concurrent code.

for a middle

Explain that detection is dynamic — both accesses must really execute — and walk through a report: the two stacks, the goroutine creation sites, and the testing package message that pins the race to one test.

for a senior

Show how the non-zero exit is what makes the detector a merge gate, and how you would spot a step that silently swallows it. Be able to separate race failures from ordinary failures in a large CI log.

for a principal

Own what a green race job entitles the organisation to claim. Make sure nobody presents it as proof of correctness, and decide what other evidence you require for code that shares state across goroutines.

## The flag is a build flag, not a runtime switch `-race` changes how the test binary is compiled. The compiler instruments the package under test and everything it links, inserting a call into the race runtime around each memory read and write and around each synchronisation operation (channel send and receive, mutex lock and unlock, atomic operations, `WaitGroup` calls, goroutine start and exit). The instrumented runtime maintains shadow state for the memory the program touches, recording which goroutine last read or wrote each location and what synchronisation had happened at that moment. Because it is a build flag, there is no way to switch it on for an existing binary. `go build -race`, `go run -race` and `go test -race` all produce a different, instrumented artefact, cached separately from the ordinary one. ## What it reports, and what it cannot The detector is *dynamic*. It reports a conflict when two goroutines actually touch the same memory location, at least one of them writing, with no synchronisation ordering the two accesses — and both of those accesses have to genuinely execute during the run. That has two consequences a junior candidate is expected to state plainly: - A race in code your tests never call is not reported. The detector saw nothing, so it says nothing. - A race in code your tests *do* call may still go unreported if, in that run, the two goroutines happened to be ordered by some synchronisation, or one of them never got far enough. Running the same suite again can produce a different verdict. So a green `-race` run is evidence that the interleavings you exercised were clean. It is not a proof of absence. ## Reading the output A report is bracketed by lines of `=` and starts with `WARNING: DATA RACE`. It names the offending access first — for example `Write at 0x... by goroutine 9:` — with a stack, then `Previous read at 0x... by goroutine 8:` with its stack, and often the stack where each goroutine was created. That second stack is the valuable half: it tells you which other code path was touching the same memory. The `testing` package checks after each test whether the race runtime recorded new reports, and if so fails that test with the message `race detected during execution of test`. That is how a race becomes attributable to a specific test rather than to the package as a whole. If a race is detected outside any running test — during package initialisation, in `TestMain` before or after `m.Run`, or in a goroutine that outlived the test that started it — there is no test to blame, and you see only the report plus `Found N data race(s)` at exit. ## The exit status By default the run does not stop at the first report; it continues, collecting more, and the race runtime uses a dedicated exit status (66 unless you override it through the `GORACE` environment variable) when the process exits because races were detected. From `go test`'s point of view the package failed: it prints `FAIL` and exits non-zero. Any CI system that treats a non-zero exit as a failed step therefore gates merges on the detector with no extra wiring — and conversely, a pipeline step that swallows that status (piping it through something that always succeeds, or only grepping the log) has silently turned the gate off. ## How you actually use it Run it locally on the package you just changed before you push, especially if the change introduced a goroutine, a shared map or a cached value: `go test -race ./cache`. When a report appears, read both stacks, find the shared location, and decide whether the fix is a mutex, a channel hand-off, giving each goroutine its own copy, or removing the sharing entirely. Re-run under `-race` to confirm the report is gone; the absence of a report on the same test that produced one is meaningful, even though absence in general is not. A common beginner mistake is to reach for `-race` when the symptom is a hang or a leak. It is a data-race detector: it has nothing to say about a deadlock, a goroutine that never exits, or a logic bug in your synchronisation that is nonetheless properly synchronised.

  • Does a clean go test -race run prove the package has no data races?
    No. The detector only observes accesses that execute in that run, so a race in a branch the tests never drive — or one whose two accesses happened to be ordered by synchronisation this time — is simply not reported. A green run says the interleavings you exercised were clean, nothing more.
  • How do you tell a race failure apart from an ordinary assertion failure in a noisy log?
    A race failure carries a `WARNING: DATA RACE` block delimited by lines of `=`, and the failing test's message is `race detected during execution of test` rather than text you wrote. At exit you also see `Found N data race(s)`. An ordinary failure shows only your own `t.Errorf` or `t.Fatalf` message.
  • Can you use the race detector on something other than tests?
    Yes. `go build -race` and `go run -race` produce the same instrumentation for an ordinary program, which is how you exercise a service under a load generator or a staging soak rather than only under unit tests. The cost is the same, so you would not ship an instrumented binary to production.

The race detector is a witness, not an auditor. It can testify in detail about the conflicting accesses it actually watched happen, and it has nothing at all to say about the ones that did not occur while it was watching.

saying these in an interview costs you the question

  • Says -race statically analyses the source for unsynchronised access
  • Claims a green -race run proves the code is race-free
  • Thinks a detected race only warns and leaves the exit status zero
  • Expects -race to find deadlocks or leaked goroutines
  • Believes -race is a runtime flag needing no rebuild
open as a page

Why is a go test -race run so much slower and more memory-hungry than the same suite without it?

level: middleimportance: should knowfreq 55%

basics

~20 s

The -race build is a separate instrumented binary that calls into the race runtime on every memory access and keeps shadow state for the memory touched. Budget roughly 2-20x the run time and 5-10x the memory, plus a full rebuild of the dependency tree.

open as a page

Your go test -race CI job now gets OOM-killed and blows its time budget. How do you get it green again without dropping the detector?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Measure per-package duration and peak memory first, then cut concurrency with go test -p and -parallel, shard the tree across jobs, shrink oversized fixtures and goroutine fan-out, and size the runner for the detector's five-to-ten-times footprint. Disabling the flag is the last resort.

open as a page

How do you decide whether go test -race runs on every pull request, only nightly, or only over some packages?

level: principalimportance: should knowfreq 40%

basics

~20 s

Weigh where a race is cheapest to catch against what the instrumented run costs. A workable split runs the detector over the concurrent packages on every pull request and the whole tree nightly and before merge, with a named owner and a review date for every exclusion.

open as a page

What does the GORACE environment variable control, and when would you set halt_on_error or history_size?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

GORACE carries space-separated options read by the race runtime at process start. halt_on_error=1 stops at the first report instead of continuing; history_size, 0 to 7 with a default of 1, enlarges each goroutine's access history so the earlier access's stack can still be printed.

open as a page