skip to content

The Race Detector

Go ships a ThreadSanitizer-based detector that names the exact pair of conflicting accesses and prints both goroutine stacks — the single most valuable tool in this whole area. Interviewers want you to know it only sees races that really happen, so it belongs in CI alongside genuinely concurrent tests.

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

questions

4

What does go test -race do, and what does its DATA RACE report contain?

level: juniorimportance: must knowfreq 72%

answer

  1. a different binary, not a different test run
  2. the compiler wraps every shared access
  3. ordering comes from channels, mutexes, atomics
  4. the report names both accesses
  5. and where each goroutine was created

basics

~20 s

go test -race builds an instrumented test binary that watches memory accesses while the tests run. When two goroutines touch the same memory unsynchronized and at least one writes, it prints WARNING: DATA RACE with a stack for each access, and the test fails.

solid answer

~50 s

`-race` is a build mode: the compiler wraps memory reads and writes with calls into a race runtime that is linked into the test binary, so `go test -race` runs a different binary from plain `go test`. While the tests execute, that runtime records which goroutine touched each piece of memory and how those accesses were ordered by real synchronization — channel operations, `sync.Mutex`, `sync.WaitGroup`, `sync/atomic`. If two accesses to the same memory are not ordered by any of that and one of them is a write, it prints a `WARNING: DATA RACE` block naming both accesses: the write, the previous conflicting access, a full stack for each, and the line where each goroutine was created. The test is marked failed, so a race in CI is a red build rather than a warning nobody reads.

code

text · 16 lines
text
==================
WARNING: DATA RACE
Write at 0x00c0000a4010 by goroutine 9:
  main.(*collector).add()
      /app/collector.go:21 +0x44

Previous read at 0x00c0000a4010 by goroutine 8:
  main.(*collector).flush()
      /app/collector.go:33 +0x38

Goroutine 9 (running) created at:
  main.(*collector).start()
      /app/collector.go:14 +0x9c
==================
Found 1 data race(s)
FAIL

go deeper

for a junior

Be ready to say what the flag does in one breath: instrumented build, watches real memory accesses, prints both conflicting accesses with stacks, fails the test. Knowing the invocation go test -race ./... is expected.

for a middle

Explain where the ordering comes from — channel operations, mutex lock and unlock, WaitGroup, atomics — and that the detector compares that ordering, not the clock, so accesses seconds apart are still reported.

for a senior

Show that you use it as a first move, not a last resort, and that you read the goroutine-creation frames rather than only the access stacks, because that is usually where the sharing was introduced.

for a principal

Own the position that a race build is a separate, expensive artifact with its own runtime budget, and be able to say what a green -race job does and does not license the team to claim.

## The flag is a build mode, not a test option `-race` is accepted by `go build`, `go run`, `go install` and `go test` alike. When you pass it, the compiler instruments the code: around reads and writes of memory that could be shared, it inserts calls into a race runtime that is linked into the resulting binary. The binary `go test -race` runs is therefore a genuinely different program from the one plain `go test` runs — same source, different object code, cached separately. Everything people say about the race detector's speed and memory use applies to that instrumented binary only; it says nothing about the build you deploy. ## What it watches while the tests run For each word of memory the program touches, the race runtime keeps a small record of which goroutines accessed it and how those goroutines were ordered relative to each other. Ordering comes from the synchronization you already write: a channel send and the receive that takes it, a `sync.Mutex` unlock and the next lock of the same mutex, `sync.WaitGroup.Done` and the `Wait` that returns after it, `sync.Once`, and the `sync/atomic` operations. When a new access arrives, the runtime asks one question: is this access ordered, through some chain of those operations, with respect to the earlier accesses to this same memory? If two accesses to the same memory are **not** ordered by anything, and at least one of them is a write, that is a data race and it is reported. Notice what is *not* in that rule: the wall-clock gap between the two accesses. Two goroutines that touch the same variable a full second apart are still reported, as long as nothing synchronizes them. You do not have to be lucky enough to hit the exact interleaving that would corrupt data — you only have to make both accesses happen in the same run. ## Reading the report A report is a block delimited by lines of `=` characters. It names the offending access first (`Write at 0x... by goroutine 9`), then the earlier conflicting one (`Previous read at 0x... by goroutine 8`), each with a stack trace pointing at file and line. Below those, `Goroutine 9 (running) created at:` gives the stack of the `go` statement that started that goroutine — often more useful than the access itself, because it shows where the sharing was introduced. At the end of the run the binary prints `Found N data race(s)` and the test fails. The address matters: the report is about a piece of memory, not a variable name. Two differently named slices that share one backing array are the same memory as far as the detector is concerned. ## What it costs An instrumented binary typically runs 2–20x slower and uses 5–10x more memory than the same tests without `-race`. That is why `-race` is a deliberate mode rather than the default, and why bolting it onto an existing CI job with tight timeouts and memory limits tends to produce timeouts before it produces race reports. ## What it is not It is not a static analyser. It never looks at code it does not execute, so it is not a substitute for review or for linting, and a clean run says only that the accesses that actually happened were properly ordered. It is also not a general concurrency checker. It does not report deadlocks — a program where every goroutine is blocked is caught by the Go runtime's own fatal `all goroutines are asleep - deadlock!` throw, not by `-race`. It does not report goroutines that never exit. And it does not judge whether your locking is *semantically* right: code that takes a mutex around each of two operations that needed to be atomic together is perfectly race-free and still wrong. ## Running it `go test -race ./...` is the usual invocation, optionally narrowed with `-run` while you are chasing one test. Because the failure is a normal test failure, it fits any CI system without special handling. The one habit worth forming early: when a concurrency bug is reported and you cannot reproduce it, running the relevant tests under `-race` is the first thing to try, not the last.

  • Does a passing go test -race run mean the package has no data races?
    No. It is a runtime detector: it only sees memory accesses that actually executed in that run. A racy path guarded by an error branch, a retry, or a config flag that no test exercises is simply never observed. A green run means the accesses that happened were properly ordered — nothing more.
  • Will go test -race catch a deadlock or a leaked goroutine?
    No. `-race` reports unsynchronized concurrent accesses only. A program where every goroutine is blocked triggers the Go runtime's own fatal `all goroutines are asleep - deadlock!` throw, and goroutines that never exit have to be found another way, such as sampling `runtime.NumGoroutine` or taking a goroutine profile.
  • Why not just build everything with -race all the time?
    The instrumented binary typically runs 2–20x slower and uses 5–10x more memory, because every shared read and write now calls into the race runtime and the runtime keeps per-word access history. That is fine for a test job and unacceptable for most production workloads, so `-race` stays a deliberate build mode.

It is a wiretap on memory rather than a code review: it only hears the conversations that actually take place while it is listening, but for those it reports both speakers.

saying these in an interview costs you the question

  • Calls it a static analysis pass over the source
  • Thinks it proves the package is race-free
  • Believes the two accesses must overlap in time
  • Expects it to report deadlocks or goroutine leaks
  • Assumes the -race binary is the one you ship
open as a page

Why can go test -race pass on a package whose code contains a real data race?

level: middleimportance: must knowfreq 58%

basics

~20 s

Go's race detector is a runtime tool: it only judges memory accesses that actually execute. If no test drives the racy path, or the code is assembly or C reached through cgo, the conflicting accesses never reach the detector and the run is green.

open as a page

A go test -race failure names a write and a previous read on a shared []byte — how do you get from that report to the fix?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Read both stacks and the goroutine-creation frames to find who shares the backing array and where it was handed over. Then fix ownership: guard every access with the same lock, copy the bytes out, or hand the buffer off — locking only the writer leaves the race intact.

open as a page

Running the whole suite under go test -race doubled CI time — how do you decide what keeps running with it?

level: principalimportance: should knowfreq 36%

basics

~20 s

Spend the budget where the detector can actually earn it: packages that start goroutines and share state, kept blocking on every pull request, with the full-suite race pass moved to merge or nightly. Then write down which paths nobody runs under -race any more.

open as a page