AlgoMaster Logo

Race Detector (-race)

High Priority22 min readUpdated June 6, 2026

Data races are the worst kind of concurrency bug: the program doesn't crash, it just produces wrong answers sometimes. Two goroutines touch the same memory without coordination, one of them writes, and the result depends on which one happened to win that nanosecond. Go ships a tool that finds these bugs at runtime called the race detector, enabled with a single -race flag. This lesson covers what a race is, how the detector works, how to read its reports, and how to wire it into CI so a race in your code never reaches production.

What Is a Data Race in Go

A data race in Go has a precise definition straight from the memory model. Two memory accesses to the same address race when:

  1. They happen in different goroutines.
  2. At least one of them is a write.
  3. They are not ordered by any synchronisation (no mutex, no channel send/receive, no sync/atomic operation, no sync.Once, no WaitGroup.Wait between them).

If all three conditions hold, the behaviour is undefined. Not "you'll get a wrong number sometimes", not "the slower goroutine wins". Undefined. The compiler is free to reorder reads and writes, the CPU is free to reorder loads and stores, the cache hierarchy is free to make a write visible to one core before another. A racy program can produce garbage values, partial writes, infinite loops, or appear to work correctly for years and then break on a new CPU model.

This is different from race conditions in higher-level logic (like a TOCTOU bug in a check-then-act sequence). Those are bugs about ordering of events. Data races are bugs about unsafe memory access. The detector finds the second kind.

Consider an online store tracking how many items a customer added to their cart. Two goroutines call cart["apple"]++. The increment isn't one instruction. It's "read the count, add 1, write the count back". If both goroutines read 3 before either writes, both write 4. One increment is lost, and nobody notices until the customer says "I added five apples but the cart shows three".

The -race Flag

The race detector is built into the Go toolchain. You don't install anything. You add -race to any of these four subcommands:

CommandWhat it does with -race
go build -raceCompiles a binary with race instrumentation. The binary is slower and bigger, but reports races at runtime.
go test -raceRuns tests with the detector enabled. Standard for CI.
go run -raceCompiles and runs a program with the detector. Standard for local debugging.
go install -raceInstalls a binary with race instrumentation. Rarely used; usually for diagnostic builds you want on the PATH.

The detector works on darwin/amd64, darwin/arm64, freebsd/amd64, linux/amd64, linux/arm64, linux/ppc64le, netbsd/amd64, and windows/amd64. On 32-bit platforms it's either unavailable or limited; the official supported set is 64-bit. If you're developing on an Apple Silicon Mac, a Linux x86 server, or a Windows x86_64 box, you're covered.

The slowdown isn't constant. Allocation-heavy code (lots of small structs, lots of garbage collection) sees the worst overhead. Tight CPU loops with little memory access see less. Either way, you don't ship -race to production. You ship a clean build, and you ensure CI never lets a racy commit get merged.

How the Detector Works

At a high level, the detector adds instrumentation to every memory access in your compiled binary. Each read and each write gets bracketed with a small piece of bookkeeping code that updates a "shadow state" tracking which goroutine touched which address most recently and what synchronisation events came before that access.

When goroutine A acquires a mutex that goroutine B previously held, the detector records a happens-before edge from B's Unlock to A's Lock. The same goes for channel send/receive pairs, sync.WaitGroup.Wait returning after Done, and sync/atomic operations. These happens-before edges form a directed graph of "this event provably happened before that one".

When the instrumentation sees a memory access, it checks two things:

  1. Is the most recent access to this address from a different goroutine?
  2. If so, is there a happens-before edge connecting the two accesses?

If the answer to question 1 is yes and question 2 is no, and at least one access is a write, the detector reports a race. It prints the current access location, the previous conflicting access location, and the create-sites of both goroutines.

The technique is called "vector clock" race detection and it's sound in one direction: if the detector reports a race, the race is real. There are no false positives. It does have false negatives, races the detector misses, because it can only see accesses that actually happen during the run. More on that shortly.

The decision is binary: either some synchronisation primitive connects the two accesses, or it doesn't. The detector doesn't care how subtle the race is. A one-in-a-million scheduling order is reported the same as an every-time race, as long as that particular order happens during the run.

A Concrete Race: Mutating a Shared Cart

Here's an online store updating a cart from two goroutines. One goroutine adds apples, the other adds bread. Both touch the same map at the same time.

Run it normally, without -race, and the output looks healthy:

Or it might be:

Or, on a different machine on a different day, it might print garbage counts and not crash. The behaviour is genuinely unpredictable, which is the whole problem. You can't write a test that reliably catches this with go test alone, because the broken outcome depends on scheduling.

Now run the same program with the race detector:

That's a real race report. The exit status 66 is the detector's signal that the program ran but a race was found. CI systems treat any non-zero exit status as a build failure, which is exactly what you want.

Reading the Race Report Line by Line

Race reports have a fixed structure. Every report has four parts.

Part 1: The header.

This delimiter starts every race report. If the program has multiple races, you get multiple blocks like this, each surrounded by ================== lines.

Part 2: The current access.

This says: goroutine 8 is currently writing to memory address 0x00c00009e000. The stack shows the runtime function mapassign_faststr (the internal map write implementation), called from main.main.func2 (the second anonymous function inside main) at line 21 of our file. Line 21 is where cart["bread"]++ sits.

The hexadecimal address isn't useful on its own. It's just an identifier the detector uses to correlate this access with the previous one. The file and line numbers are what you act on.

Part 3: The previous conflicting access.

Same address, different goroutine, different line. Goroutine 7 wrote there earlier (line 15, cart["apple"]++), and there was no synchronisation between that write and the one on line 21. That's the race.

The label says "Previous write", but it can also say "Previous read". If your current access is a read, the report flips to "Read at ... by goroutine X" and "Previous write at ... by goroutine Y". The detector reports one read-write or write-write pair per race. Read-read pairs aren't races, so they're never reported.

Part 4: The goroutine create-sites.

This tells you where each goroutine was spawned (go func() { ... }()). Useful when the racy function is shared by many call sites and you need to know which one actually fired this report. Note the state labels: running means the goroutine is still alive, finished means it already exited but the detector still has its history.

Fixing the Race with sync.Mutex

The cart race comes from two goroutines hitting a map without coordination. The fix is to give them a shared lock and require holding it before any access.

Note that the final read after wg.Wait() is also wrapped in Lock/Unlock. Strictly speaking, wg.Wait() returning establishes a happens-before edge from every wg.Done() to that point, so the read is safe. But locking it anyway means the code stays correct if you later add another writer that doesn't end before the read.

Now verify with the detector:

No WARNING: DATA RACE block. No "Found N data race(s)" footer. The exit status is 0. The race is gone.

If you're running this inside a go test -race ./... invocation, a clean run looks like this:

When a test catches a race, the output shifts to include the race report plus a FAIL line:

Treat any race report from CI as a build break, exactly like a failing assertion or a compile error. Don't ignore it, don't retry, don't merge.

What the Detector Catches and Misses

The detector is sound (no false positives) but incomplete (false negatives are possible). Knowing the boundary is important so you don't develop false confidence.

CategoryBehaviour
Concurrent unsynchronised access on a code path that runsCaught. Reported with file, line, and goroutines.
Race on a code path that never runs during the testMissed. The detector only sees executed accesses.
Race requiring a specific schedule that didn't occurMissed. Run again or use -count=N to get different scheduling.
Race in vendored or third-party codeCaught (the instrumentation covers all code in the build).
Race inside C code reached via cgoNot instrumented. Cgo memory access is invisible to the detector.
Race on memory shared across processesNot detected. The detector is single-process only.
Higher-level race conditions (TOCTOU, missing cancellation, ordering bugs)Not detected. The detector finds memory races, not logical bugs.
Runtime cost (CPU)5x to 10x slower in typical workloads.
Runtime cost (memory)5x to 10x more memory used for shadow state.
Binary sizeRoughly 2x larger than a clean build.

The "code path that never runs" gap is the most common source of escaped races. A function exercised only by an error branch your tests don't trigger might be fully racy, and the detector won't see it until production traffic exercises that branch. The fix is test coverage. The detector amplifies test coverage; it doesn't replace it.

The "specific schedule" gap is more subtle. Even if your test exercises every code path, the Go scheduler might happen to run goroutines in a serial order during the test, missing an interleaving that production hits. Running tests with -count=10 or -count=100 is a cheap way to expose schedule-dependent races. Some teams run a nightly job at -count=1000 against the most concurrency-sensitive packages.

CI Integration

Every PR should run the test suite with -race. Here's a minimal GitHub Actions snippet that does it:

The crucial line is go test -race ./.... The trailing ./... runs every package in the module. Any package without tests is silently skipped, which is fine, the detector only watches code that runs.

A few practical refinements teams add:

  • Add -count=1 to disable test result caching. Cached results don't re-run the detector. (The -race flag does invalidate the cache, but explicitly setting -count removes any doubt.)
  • Add -timeout=5m (or longer) because race-instrumented tests run slower and might exceed the default 10-minute test timeout on slower runners.
  • Add -short if you have a testing.Short() switch for long-running tests, and run the full suite less often. The fast race-checked suite catches most issues; the long suite catches a few extra schedule-dependent ones.

A more thorough CI step:

For a project with both fast unit tests and slow integration tests, split into two jobs:

The second job re-runs the integration tests ten times to expose schedule-sensitive races, but only on labelled tests so you don't 10x the cost of the entire suite.

Tuning the Detector with GORACE

The GORACE environment variable configures the detector's runtime behaviour. It's set on the command line, separate from the -race build flag:

The format is space-separated key=value pairs. The most useful options:

OptionDefaultWhat it does
halt_on_error0Exit immediately on the first race report (skip the rest of the run). Default behaviour is to keep running and report multiple races, but a buggy program might cascade after the first race, so halting can give cleaner output.
history_size1Controls how much per-goroutine history the detector keeps. Larger values catch more races but use more memory. Range is 0 to 7; each step doubles the buffer. Set to 7 if you suspect races the detector is missing.
log_pathstderrWhere race reports are written. /path/to/file writes to a file (one per goroutine, with the goroutine ID appended). stderr (default) is fine for most uses; stdout redirects to standard out.
exitcode66The exit code when a race is detected. 66 is the historical default. Change it if you need a different signal for some CI integration.
strip_path_prefix""Strip this prefix from file paths in reports. Useful in containerised builds where the source path differs from the local checkout.

For most projects, you won't touch GORACE. The defaults are tuned for the common case: run tests, report all races to stderr, exit with code 66 if any fire. Use history_size=7 only when you've got reproducer code that you're certain races but the detector isn't catching, which usually means the race involves a long chain of accesses that overflow the default history buffer.

How the Detector Behaves Across Multiple Races

A single go test -race run can find multiple races, but with caveats. By default, the detector reports each unique race it sees during the run. "Unique" is determined by the pair of stack traces involved; the same code path firing thousands of times reports once.

If halt_on_error=1 is set, the detector exits at the first race, which is useful when:

  • You suspect later races are downstream effects of the first one (one race corrupts state, which causes more apparent "races" that are actually a single root cause).
  • Your CI output is hard to read with multiple race reports, and you'd rather see one and fix it.

Without halt_on_error, you'll see all distinct races in one run. That can be overwhelming on a freshly-broken codebase, but it's efficient for big migrations where you want to inventory everything before fixing.

A typical workflow:

  1. Run go test -race ./... locally.
  2. See a race report. Fix it. Rerun.
  3. See another race report. Fix it. Rerun.
  4. Run finishes clean with ok for every package and exit code 0.
  5. Push the branch; CI runs the same command and confirms.

Don't try to fix multiple races at once unless they're obviously related. Each fix is a chance to introduce a new bug, and the detector running between each one is your safety net.

Common Gotchas

Even teams that use -race religiously hit a few recurring issues. Knowing them ahead of time saves debugging hours.

Tests that don't exercise concurrency at all. Adding -race to a single-goroutine test does nothing useful; there's nothing for the detector to flag. The detector only finds races that occur during execution, so the test has to actually run concurrent code. If a function takes a chan or spawns goroutines, the test should exercise that, ideally with multiple inputs in parallel using t.Parallel() or a sync.WaitGroup.

Flaky races that need `-count=N`. Some races only appear under specific scheduling. A single-run test might pass even with -race. Bumping to -count=10 or -count=100 exposes schedule-dependent races. If your CI has a known flaky race that "sometimes" appears, run with high count locally until you reproduce it.

Production binaries built with `-race` by accident. A Makefile rule with go build -race slipped in during debugging can ship to production. The binary runs 5x slower and uses 10x the memory. Add a check to your release pipeline that rejects race-instrumented binaries. The go version -m <binary> output shows build settings; look for -race=true.

False sense of safety from clean runs. A clean go test -race ./... doesn't prove the code is race-free. It proves that this particular test run, on this particular schedule, didn't observe a race. Combine -race with high test coverage, fuzz tests, and load tests for stronger confidence.

Cgo races. If your Go code calls C functions that share memory with Go code, the C side isn't instrumented. The detector can flag the Go-side accesses but won't see the C-side accesses. Avoid sharing mutable memory across the cgo boundary.

Time-based scheduling assumptions. Test code that uses time.Sleep to "wait for the other goroutine" is brittle. The detector doesn't care about your sleeps; if the other goroutine ran before the sleep finished, the test sees one outcome, otherwise another. Use channels, sync.WaitGroup, or sync.Cond for synchronisation in tests.

When Not to Use the Race Detector

The detector is essential during development and CI. There are a few situations where it's the wrong tool.

In production. The 5x-10x slowdown is a non-starter for serving traffic. Build production binaries clean. The detector catches races in CI; production should never see one that wasn't already caught.

For non-race bugs. If your bug is "the function returns the wrong value when called with input X", that's a logic bug, not a race. Adding -race won't help. Use -race when you suspect ordering or memory-sharing issues, not for general debugging.

On 32-bit targets. The detector's official support is 64-bit. If you're targeting an old embedded platform on linux/arm (32-bit), you'll either get a build failure or unsupported behaviour. Test your concurrency on a 64-bit machine; the bugs you find there apply equally to the 32-bit target.

For benchmarking. go test -bench=. -race is technically possible, but the instrumentation overhead distorts every result. Benchmark without -race to get realistic numbers, and run a separate -race pass for correctness.

Putting It Together

A reasonable Go service development workflow uses the detector at three points:

The flow shows the detector running everywhere it's cheap (local, pre-commit, CI) and absent where it's expensive (production). Each step catches races that earlier steps missed: local dev catches the obvious ones, pre-commit catches the ones the developer didn't notice, CI catches the ones that only appear on different hardware or with different timing. By the time a binary reaches production, the chance of an undetected race is small enough that the 5x slowdown of -race in production isn't worth paying.

A small end-to-end demo brings the pieces together. Two goroutines race on an inventory counter; the detector catches it; we fix it with a mutex; the detector verifies the fix.

No race report, exit code 0. The mutex embedded in the Inventory struct protects every read and write of the shared map, so concurrent goroutines have a clean happens-before edge through Lock and Unlock. This is the pattern to use whenever shared state needs concurrent access: bundle the lock with the data it protects, and expose only methods that acquire the lock internally.

Quiz

Race Detector Quiz

10 quizzes