Go ships a race detector that finds data races at runtime. Here's how to use it in tests, CI, and staging.

How to find data races in Go with the race detector


Data races are the kind of bug that waste an afternoon. Two goroutines touch the same variable, one of them writes, and the program behaves differently from one run to the next. You stare at the code and it looks fine. You add a print statement and the bug disappears. You remove it and the bug comes back.

Go’s built-in race detector helps you catch these bugs while the code is running. It instruments memory accesses at compile time and watches for conflicting, unsynchronized accesses at runtime. When it finds one, it prints a report with exact file names, line numbers, and goroutine stacks, then exits with a non-zero status unless you configure it differently.

Under the hood, it uses ThreadSanitizer, a dynamic race detection library originally from Google.

How to enable the Go race detector

Add -race to any go command:

# Run tests with race detection
go test -race ./...

# Build a race-instrumented binary
go build -race -o myapp .

# Run directly
go run -race main.go

go test -race is the most common entry point, and the easiest to wire into CI. If your pipeline doesn’t already run it, that’s the single most impactful change you can make today. Many teams gate every pull request on it.

The tradeoff: race-instrumented binaries use roughly 5-10x more memory and run 2-20x slower. Don’t ship them to production permanently. Running them in staging or canary environments, though, can catch races that only appear under real traffic patterns.

Reading a race detector report

When the detector finds a race, it prints something like this:

WARNING: DATA RACE
Read at 0x00c0000b4010 by goroutine 7:
  main.(*Counter).Read()
      /home/user/counter.go:14 +0x3c

Previous write at 0x00c0000b4010 by goroutine 6:
  main.(*Counter).Increment()
      /home/user/counter.go:10 +0x4c

Goroutine 7 (running) created at:
  main.main()
      /home/user/main.go:22 +0x118

Goroutine 6 (running) created at:
  main.main()
      /home/user/main.go:20 +0xd8

Three things to look at:

  1. What happened — a read and a write to the same address with no synchronization between them.
  2. Where — exact file names, line numbers, and function names for both accesses.
  3. Who started them — the full creation stack for each goroutine.

The goroutine creation stacks are often the most useful part. They show you which go statement launched the goroutine, which tells you why those two goroutines were running concurrently in the first place.

Reproducing timing-sensitive races

Some races only trigger under specific scheduling. A test might pass 99 out of 100 runs and fail once. Here’s how to flush them out.

Run tests many times. The -count flag repeats tests:

go test -race -count=100 ./...

Crank up GOMAXPROCS. More parallelism means different goroutine scheduling, which can surface races that hide on a single core:

GOMAXPROCS=4 go test -race -count=50 ./...

Stress the scheduler. Drop runtime.Gosched() calls in suspect code during debugging. This yields the processor and increases the chance of interleaving that triggers the race.

If a race only shows up under production-like load, build a race-enabled binary with go build -race and run it in staging. The GORACE environment variable controls behavior — GORACE="log_path=/tmp/race" writes reports to a file instead of stderr, which is easier to collect from long-running services.

Common data race patterns and how to fix them

Unprotected shared counter

The most common data race in Go. Two goroutines increment a variable with no synchronization:

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	count int
}

func (c *Counter) Increment() {
	c.count++ // DATA RACE
}

func (c *Counter) Read() int {
	return c.count // DATA RACE
}

func main() {
	c := &Counter{}
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Increment()
		}()
	}

	wg.Wait()
	fmt.Println(c.Read())
}

Fix with sync.Mutex:

type Counter struct {
	mu    sync.Mutex
	count int
}

func (c *Counter) Increment() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.count++
}

func (c *Counter) Read() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.count
}

For a simple counter, sync/atomic is lighter:

import "sync/atomic"

type Counter struct {
	count atomic.Int64
}

func (c *Counter) Increment() {
	c.count.Add(1)
}

func (c *Counter) Read() int64 {
	return c.count.Load()
}

Accidental closure capture in older Go modules

This one is subtle if you maintain code that still uses the pre-Go 1.22 loop semantics. A goroutine captures a loop variable that mutates underneath it:

for _, item := range items {
	go func() {
		process(item) // DATA RACE: item changes on next iteration
	}()
}

Fix by passing the variable as a function argument:

for _, item := range items {
	go func(it string) {
		process(it)
	}(item)
}

Go 1.22 changed loop variable scoping so each iteration gets its own variables for modules that declare go 1.22 or later in go.mod. If your module has opted into those semantics, this specific range-loop pattern is no longer a race. The broader lesson still matters: be deliberate about what goroutines capture, especially when the captured value is reused outside the loop body.

Unsynchronized map access

Maps in Go are not safe for concurrent use. Reading and writing from multiple goroutines is a race, and Go will sometimes crash with concurrent map writes even without the race detector:

m := make(map[string]int)

go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()

Use sync.Mutex to wrap map access, or use sync.Map if your access pattern is read-heavy with infrequent writes.

Leaking goroutines with shared state

Goroutines that outlive their expected lifetime can create races when the data they reference gets reused or modified elsewhere. If you’re dealing with goroutine lifecycle problems, Common Goroutine Leaks in Go and How to Avoid Them covers the patterns that prevent this.

What the race detector cannot do

It only finds races that actually execute during a run. If a code path isn’t exercised, its races go undetected. This is why test coverage matters — the race detector is only as good as the code paths your tests hit. It also can’t detect deadlocks or other concurrency bugs that aren’t data races.

Architecture support is limited too. The Go documentation currently lists support for linux/amd64, linux/ppc64le, linux/arm64, linux/s390x, linux/loong64, freebsd/amd64, netbsd/amd64, darwin/amd64, darwin/arm64, and windows/amd64. The detector also requires cgo, and on non-Darwin systems you need a C compiler installed.

A practical checklist

  1. Add go test -race ./... to CI. Highest-value step, lowest effort.
  2. Run race-enabled binaries in staging when investigating production concurrency issues.
  3. Use -count to stress flaky races that don’t reproduce easily.
  4. Protect shared state with sync.Mutex, sync.RWMutex, sync/atomic, or channels.
  5. Prefer sending data over channels to sharing memory when the design allows it.
  6. Use defer for unlock calls so you don’t forget to release locks. More on this in How to defer in Golang like a pro.

The overhead of -race in tests is almost always worth it. Most Go code is concurrent whether you meant it to be or not — an HTTP handler, a background worker, a goroutine you spun up “just for this one thing.” The race detector is how you find out what you missed before your users do.