Skip to content

The race detector

Level: 201 · anyone whose goroutines share a variable

One line: Built with -race, a program in which two goroutines touch one variable without synchronization prints WARNING: DATA RACE and exits 66 — even with one thread running Go code at a time — but only on a run in which that happens: the same binary, given input that keeps the second goroutine away, exits 0 and says nothing.

The question

Workers count orders into one int, with no lock:

orders := 0
var wg sync.WaitGroup
for range workers {
    wg.Go(func() {
        for range 1000 {
            orders++ // read, add one, write back: no lock
        }
    })
}
wg.Wait()

orders++ reads the variable, adds one and writes the result back, and two goroutines doing that at once can write over each other's increments. The program compiles, runs and exits 0 either way. How many increments a run loses changes from run to run, so this page never prints the count; the Rust library's Data races ↗ counts the losses for the same loop in C.

The race detector ↗ finds this bug without needing the lost increment to show. Pass -race to go build, go run or go test, and the compiler instruments every memory access with a record of when and how it happened, while a runtime library watches for unsynchronized access to shared variables. That library is based on ThreadSanitizer ↗, the LLVM project's race detector — the one C and C++ programs get from -fsanitize=thread.

The script builds the program with and without -race, and runs it with one worker and with two:

Verified output of the_race_detector_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go build -o counter .
$ ./counter 2 >/dev/null
exit status 0, race report: no
$ go build -race -o counter_race .
$ ./counter_race 1 >/dev/null
exit status 0, race report: no
$ ./counter_race 2 >/dev/null
exit status 66, race report: yes
lines of counter.go the report points at:
  counter.go:20  wg.Go(func() {
  counter.go:22  orders++ // read, add one, write back: no lock
$ GOMAXPROCS=1 ./counter_race 2 >/dev/null
exit status 66, race report: yes
$ go run -race . 2 >/dev/null
exit status 1, race report: yes
go run's last line on stderr: exit status 66

Reading the output

./counter 2: exit status 0, no report. Without -race nothing checks. Whatever the count came to, the program ran to the end and succeeded.

./counter_race 1: exit status 0, no report. The same instrumented binary, given one worker. The code is exactly as unsafe as before, but in this run only one goroutine touched orders. The article puts it plainly: the detector "only finds races that happen at runtime", so it cannot see a race in a code path the run did not take.

./counter_race 2: exit status 66, report. Two workers, and the race happened. 66 is the default of the exitcode option in the article's list of GORACE settings: the status a program exits with after a detected race. The report points at two lines — orders++, where the conflicting accesses happened, and wg.Go, where the two goroutines were created.

GOMAXPROCS=1 ./counter_race 2: exit status 66, report. GOMAXPROCS limits how many operating-system threads can execute Go code at the same time (runtime). At 1, the two workers never ran at the same instant, and the detector reported the race anyway. It does not need to catch two accesses overlapping: it checks whether anything — a lock, a channel operation, a WaitGroup — places one access before the other, the happens before relation of the Go memory model ↗. Here nothing does.

go run -race . 2: exit status 1, and exit status 66 on stderr. go run reports the program's status in a line of its own and exits 1 itself, so a CI step that uses go run -race sees 1, not 66. Under go test -race, the test fails instead: the last block of synctest.Wait instead of a sleep shows race detected during execution of test and exit status 1.

the_race_detector_sh.sh

the_race_detector_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The race detector, on a counter that goroutines increment without a lock.
# Built without -race, the program exits 0 and says nothing. Built with -race,
# it prints a report and exits 66 -- on a run in which the race happens. The
# count, the addresses and the goroutine numbers change from run to run, so
# this script prints none of them: only exit statuses, whether a report
# appeared, and which lines of counter.go the report points at.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
export GOTOOLCHAIN=local

cat >counter.go <<'GO'
// Command counter counts orders with as many workers as its argument says,
// and no lock around the count.
package main

import (
    "fmt"
    "os"
    "strconv"
    "sync"
)

func main() {
    workers, err := strconv.Atoi(os.Args[1])
    if err != nil {
        panic(err)
    }
    orders := 0
    var wg sync.WaitGroup
    for range workers {
        wg.Go(func() {
            for range 1000 {
                orders++ // read, add one, write back: no lock
            }
        })
    }
    wg.Wait()
    fmt.Println("orders:", orders)
}
GO

say() { printf '$ %s\n' "$*"; }

# The exit status in $1, and whether the last run's stderr held a race report.
verdict() {
    if grep -q '^WARNING: DATA RACE$' stderr.txt; then
        echo "exit status $1, race report: yes"
    else
        echo "exit status $1, race report: no"
    fi
}

say 'go mod init example'
go mod init example 2>/dev/null || exit 1

say 'go build -o counter .'
go build -o counter . || exit 1

say './counter 2 >/dev/null'
./counter 2 >/dev/null 2>stderr.txt
verdict $?

# -race needs cgo, and on Linux a C compiler; without them this stops here.
say 'go build -race -o counter_race .'
go build -race -o counter_race . || exit 1

say './counter_race 1 >/dev/null'
./counter_race 1 >/dev/null 2>stderr.txt
verdict $?

say './counter_race 2 >/dev/null'
./counter_race 2 >/dev/null 2>stderr.txt
verdict $?

echo 'lines of counter.go the report points at:'
grep -o 'counter\.go:[0-9]*' stderr.txt | sort -u -t: -k2,2n | while IFS=: read -r file line; do
    printf '  %s:%s  %s\n' "$file" "$line" "$(sed -n "${line}s/^[[:space:]]*//p" counter.go)"
done

say 'GOMAXPROCS=1 ./counter_race 2 >/dev/null'
GOMAXPROCS=1 ./counter_race 2 >/dev/null 2>stderr.txt
verdict $?

say 'go run -race . 2 >/dev/null'
go run -race . 2 >/dev/null 2>stderr.txt
verdict $?
echo "go run's last line on stderr: $(tail -n 1 stderr.txt)"

A report, and why the key leaves it out

The report is the useful part, and almost nothing in it repeats between runs:

Real runs — go1.25.5, x86-64 Mac, macOS 26.6.2, 1 run and then 20 + 20, 2026-09-14
One run of ./counter_race 2, its first report:
==================
WARNING: DATA RACE
Read at 0x00c000012138 by goroutine 8:
  main.main.func1()
      example/counter.go:22 +0x37
  sync.(*WaitGroup).Go.func1()
      sync/waitgroup.go:239 +0x5d

Previous write at 0x00c000012138 by goroutine 7:
  main.main.func1()
      example/counter.go:22 +0x49
  sync.(*WaitGroup).Go.func1()
      sync/waitgroup.go:239 +0x5d

Goroutine 8 (running) created at:
  sync.(*WaitGroup).Go()
      sync/waitgroup.go:237 +0x86
  main.main()
      example/counter.go:20 +0xf2

Goroutine 7 (finished) created at:
  sync.(*WaitGroup).Go()
      sync/waitgroup.go:237 +0x86
  main.main()
      example/counter.go:20 +0xf2
==================

Reports per run, over 20 runs with GOMAXPROCS=unset:
     4 runs printed 1 report(s)
    16 runs printed 2 report(s)

Reports per run, over 20 runs with GOMAXPROCS=1:
    20 runs printed 1 report(s)

Read at and Previous write at are the two conflicting accesses, each with the stack that made it; the two created at blocks say where each goroutine came from. The address, the goroutine numbers, which goroutine read and which wrote, whether the other had finished, and how many reports a run prints all change between runs — so the example script prints only the exit status, whether a report appeared, and the lines of counter.go that it names. The demo builds with -trimpath, which is why the paths read example/counter.go and sync/waitgroup.go; the detector's own strip_path_prefix option does a similar job.

To count on your own machine, run bash demo/race_reports.sh from this folder, or bash demo/race_reports.sh 100 for more runs.

On Linux, -race needs a C compiler

The article's requirements: cgo must be enabled, and on systems other than macOS a C compiler must be installed; the supported platforms include linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 and windows/amd64. CI's Ubuntu runner has one. Checked by hand in Docker on 2026-09-14 with go1.25.14: golang:1.25-alpine ships without a C compiler, so cgo is off and go build -race stops with go: -race requires cgo; enable cgo by setting CGO_ENABLED=1; after apk add gcc musl-dev the program built and exited 66 on alpine's musl, as it does in the Debian-based golang:1.25. The example script exits 1 when the -race build fails, rather than record a key without a detector in it. (Not machine-checked here.)

What it costs

The article's estimate for a typical program is 5 to 10 times the memory and 2 to 20 times the execution time. The 2013 blog post that introduced the detector draws the practical conclusion: too expensive to leave on everywhere, so run it in tests, especially load and integration tests that exercise the concurrent code, or on one race-enabled instance among many serving real traffic.

What to do

  • Run go test -race in CI. The article's advice starts there. The detector sees only what the tests execute: a test that never runs two goroutines against the same data cannot catch a race between them — the ./counter_race 1 line above.
  • Give it real concurrency to watch: tests that start the goroutines production starts and, for code the tests reach poorly, a -race build under a realistic workload.
  • Treat a report as a bug, not a flake. It means nothing orders the two accesses, whatever the count said. Fix it with a mutex, an atomic counter, or by handing the data over a channel — never with a sleep.
  • In a synctest test, use synctest.Wait, which the detector counts as synchronization — synctest.Wait instead of a sleep.

In other languages

  • Rust — the Rust library's Data races ↗ runs ThreadSanitizer, the runtime Go's detector is built on, over a C counter, and finds that it reports the race even on a run where the arithmetic came out right; the GOMAXPROCS=1 line above is the Go version of that point. The same page shows safe Rust refusing to compile the unsynchronized counter at all, and Send and Sync outlines the two traits behind that refusal. Go's check runs on the paths a run takes; Rust's runs at compile time, on all of them.
  • Concurrency library — chapter 02, Shared state (data races and lost updates, mutexes, atomics), and chapter 09, Testing and tools (ThreadSanitizer, Go's race detector, stress tests), are planned.

Sources

  • Data Race Detector ↗ — usage, the report format, the GORACE options with exitcode (default 66), "How To Use", requirements, and runtime overhead.
  • Introducing the Go Race Detector — Dmitry Vyukov and Andrew Gerrand, the Go blog, 26 June 2013: how it works, built on ThreadSanitizer, first shipped in Go 1.1.
  • src/runtime/race/README — the runtime library, based on ThreadSanitizer from LLVM's compiler-rt.
  • runtime, Environment Variables ↗ — what GOMAXPROCS limits.
  • The Go Memory Model ↗ — what a data race is, and what orders one event before another.
  • Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 15, "Writing Tests" — the section on finding concurrency problems with the data race detector.
  • James Cutajar, Learn Concurrent Programming with Go (Manning, 2024), chapter 3, "Thread communication using memory sharing" — the section on the Go race detector.
  • Katherine Cox-Buday, Concurrency in Go (O'Reilly, 2017), the appendix — the section on race detection.