Deep Engineering
Advanced·Published·30 MIN

Data races in Go: what breaks, what -race sees and what it does not

Interview questions on races climb a ladder: what a data race is — how it differs from a race condition — how to find one — will -race find every race — can you turn it on in production. A run answers each step: a two-word struct without synchronisation hands out pairs nobody ever wrote; -race finds a race that did not spoil the result and misses one that never executed; and a check and a withdrawal under separate mutex acquisitions drive the balance negative in 936 cases out of 1000 with zero reports.

Full technical treatment

TL;DR

A data race is a write to a memory location concurrent with another access to the same location, with no synchronisation between them. That is how the Go memory model defines it. The definition says nothing about a wrong result: a race is the absence of order between accesses, not a lost increment.

Hence the main consequence: there are no benign races. A two-word struct into which the writer always puts equal numbers turned out torn in 53–75% of the reader's unsynchronised copies: pairs nobody ever wrote. For interfaces, slices, strings and maps the memory model allows "arbitrary memory corruption". And a for !done {} loop on an unsynchronised flag, by the same model, is not obliged to finish at all.

Beyond that comes what separates real understanding from having read about it. The -race detector looks not for a wrong answer but for unordered accesses: a race after which the program printed the correct n = 2000 was still found, and the process exited with code 66 — the default value. But it sees only what happened in this run: a race on a path that did not execute — zero reports; a racy program whose accesses a lock happened to order in this run — also zero. And it does not see a race condition without a data race: a check and a withdrawal under separate acquisitions of one mutex drove the balance negative in 936 cases out of 1000, with zero reports. The cost of the mode on the measured workload: memory ×4.8–5.0, time ×7.6–12. So its place is regular CI runs and staging, and in production at most a limited diagnostic run, not the whole fleet.

Where to start
Before this lesson it is enough to understand
  • what a goroutine is and how to start several;
  • that n++ from several goroutines loses updates and what a mutex and atomic operations do about it — that is the lesson on sync and atomic;
  • what happens-before is, at least roughly — from the same lesson.
You do not need to know in advance
  • the definition of a data race from the memory model and how it differs from a "race condition";
  • torn values, a loop on an unsynchronised flag;
  • how to read a -race report, what it does not find and what it costs.

What is really being asked

The ladder is almost always this one:

  1. "What is a data race?" — "when a counter loses increments" is incomplete, and that is where candidates get caught.
  2. "How does a data race differ from a race condition?" — the question the topic exists for.
  3. "How do you find a race?" — -race, and that is half the answer.
  4. "Will -race find every race?" — no, and you need to be able to say which ones it will not find.
  5. "Can you turn on -race in production?" — about the cost.
  6. "A race on a boolean flag is harmless, isn't it?" — about there being no harmless ones.

The ladder has one spine: a race is not a wrong result but the absence of order between accesses. A wrong result is only one of its symptoms, and not the most dangerous.

Base: a definition without the word "result"

The memory model's definition is short:

A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.

— The Go Memory Model
language contractThe definition and its consequences are the Go memory model. Everything below about the share of torn copies and the cost of -race is measured on one machine, and what is stable there is the direction, not the numbers.

Three words in it carry the whole topic.

  • "Write". Two concurrent reads are not a race. At least one write is needed.
  • "Concurrently". Not "at the same instant" but without order: between the accesses there is no mutex, no channel, no atomic operation — nothing that creates happens-before (covered in the lesson on sync and atomic).
  • "Unless atomic". If all accesses to the location are atomic, it is not a race by definition. An atomic write plus a plain read of the same location is a race.

The words "wrong result" are not in the definition — and the junior-level answer does without them too: a data race is when two goroutines access one variable, at least one writes, and there is no synchronisation between the accesses. It is fixed with a mutex, a channel or an atomic operation; it is found with the -race flag.

Lost increments in n++ are the best-known symptom, and they are covered in the lesson on sync and atomic. Here is what follows from the definition beyond that.

Mechanism 1: a value nobody wrote

The writer puts one and the same number into both fields of a two-word struct: {1, 1}, {2, 2}, {3, 3}… The reader copies the struct without synchronisation and checks whether the fields are equal:

GO
type pair struct{ a, b int64 }
 
// writer:  shared = pair{i, i}
// reader:  x := shared; if x.a != x.b { torn++ }
1) TORN VALUE: struct{ a, b int64 }
───────────────────────────────────
  the writer always stores {i, i}; the reader copies without a lock
  round         read                a != b     share
  1              90575872         49682570    54.9%
  2              83404800         62725161    75.2%
  3             118076416         62609070    53.0%
  4              70474752         52215301    74.1%
  5              93927424         68372908    72.8%

In this run more than half the copies are pairs like {17, 16} that nobody ever wrote. Copying a two-word struct is not one indivisible read, and the writer manages to wedge itself in the middle. The memory model describes exactly this:

races on multiword data structures can lead to inconsistent values not corresponding to a single write.

— The Go Memory Model

With numbers this is "merely" wrong data. But in Go there are multiword values that themselves consist of a pointer and a length or a pointer and a type: an interface, a slice, a string; a map keeps such pairs in its internal structures. A torn slice is a pointer to one array and a length from another; the memory model names the consequence for all four outright — "arbitrary memory corruption". The program does not lose an increment; it reads and writes where it must not.

The share in the table is noisy from round to round and from machine to machine. Only one thing is stable: it is not zero. That is enough to answer "but what if the race is benign": a benign race has no way to guarantee the value is even whole.

Mechanism 2: a flag that may never arrive

The most "harmless" race is a boolean stop flag:

GO
var done bool
 
go func() {
    for !done {
    }
    // ...
}()
 
done = true

The memory model analyses the same loop — in its version the loop sits in main and a started goroutine writes the flag — and answers firmly:

The loop in main is not guaranteed to finish.

— The Go Memory Model

Between the write done = true and the read in the loop there is not a single synchronisation event, and nothing obliges the reading goroutine ever to see the write. And what does a run show?

2) FLAG WITHOUT SYNCHRONISATION: for !done {}
─────────────────────────────────────────────
  on this version the loop saw the write and finished

This is worth saying in an interview word for word: on go1.24.7 the loop finished — and that is an observation, not a guarantee. That is exactly how a race survives in code: it works until the compiler, the processor or the load changes. The correct flag is atomic.Bool or closing a channel; context was made for this.

Mechanism 3: what -race sees — order, not result

-race is a build flag: go test -race, go run -race, go build -race. The program is built differently and, while running, watches memory accesses, looking for pairs of accesses to one location with no happens-before between them. The important words here are pairs of accesses, not wrong answers.

Two goroutines each do n++ a thousand times. On this machine the result came out correct:

1) A RACE THAT DID NOT SPOIL THE RESULT
───────────────────────────────────────
  without -race: n = 2000, exit code 0
  with -race, exit code 66:
    ==================
    WARNING: DATA RACE
    Read at 0x… by goroutine 8:
      main.main.func1()
          racy/counter/main.go:18

    Previous write at 0x… by goroutine 7:
      main.main.func1()
          racy/counter/main.go:18

The detector found the race anyway — because it looks not for an error in the answer but for a write and a read of one location without order. The report reads top to bottom:

  • Read at … by goroutine 8 — where the second access was;
  • Previous write at … by goroutine 7 — which access it is unordered with;
  • Goroutine … created at (further down in the full output) — where both goroutines came from.

And exit code 66 is no accident: that is how the detector marks, by default, a process in which it found a race. It is a setting, not a guarantee: the GORACE environment variable changes it (the run of bench/gorace/options.go):

1) EXIT CODE: DEFAULT AND VIA GORACE
────────────────────────────────────
  without GORACE:      reports 1, exit code 66
  GORACE=exitcode=13:  reports 1, exit code 13

In go test -race the same reaches the test: the testing package marks it failed even if every assertion passed.

4) go test -race ON A TEST WHOSE ASSERTIONS ALL PASSED
──────────────────────────────────────────────────────
    --- FAIL: TestCounter
    testing.go:1490: race detected during execution of test
    FAIL	deepengineering/bench/gorace/racy/testpkg
  go test exit code: 1

That is why CI with go test -race goes red on a race, not only on a wrong answer.

exitcode (default 66): The exit status to use when exiting after a detected race

— Data Race Detector

And a report does not need simultaneity. A goroutine writes x, main reads it 100 ms later — the accesses are far apart in time, but there is no order between them:

1) A RACE NEEDS NO SIMULTANEITY
───────────────────────────────
  racy/seq: a goroutine writes x, main reads it 100 ms later
  GOMAXPROCS=2: report in 5 runs of 5, exit codes [66 66 66 66 66]
  GOMAXPROCS=1: report in 5 runs of 5, exit codes [66 66 66 66 66]

The report appears even on a single thread. The detector does not need the accesses to coincide in time — it needs there to be no happens-before between them.

Mechanism 4: what -race does not see

Three boundaries, and all of them matter more than what the detector finds.

The first: only what happened. A different program — racy/branch: its only race is on a rare path enabled by an argument:

2) A RACE ON A PATH THAT DID NOT EXECUTE
────────────────────────────────────────
  with -race, normal path: exit code 0, reports 0
  with -race, rare path:   exit code 66, reports 1
  in the report: racy/branch/main.go:24

One and the same binary built with -race. On the normal path — clean; on the rare one — a report. The documentation says so directly:

The race detector only finds races that happen at runtime, so it can't find races in code paths that are not executed.

— Data Race Detector

The second: only the execution that happened. The write and the read of x both bypass the lock, but after the write the goroutine takes and releases mu, and main takes it before the read:

2) A RACY PROGRAM WITH NO RACE IN THIS EXECUTION
────────────────────────────────────────────────
  racy/missed: write and read of x bypass the lock; the lock is taken
  after the write in the goroutine and before the read in main
  reports over 5 runs: 0, exit codes [0 0 0 0 0]

The path ran — zero reports. It is worth naming this precisely: in these runs Unlock in the goroutine happened before Lock in main, the accesses to x were ordered through the lock, and in this execution there was no data race. It is the program that is racy: nothing in it guarantees that order, and with a different interleaving there would be a race. The detector checks the execution, not every execution the program allows.

Hence the practical point: a green go test -race does not prove there are no races. On paths that did not execute it does not see them at all; on executed ones, only those that showed up in that execution. That is why tests where several goroutines work on shared state help, and so do runs with -count above one.

The third: a race condition without a data race. An account where every access to the balance is under a mutex:

GO
func (a *Account) Balance() int   { a.mu.Lock(); defer a.mu.Unlock(); return a.bal }
func (a *Account) Withdraw(n int) { a.mu.Lock(); defer a.mu.Unlock(); a.bal -= n }
 
// two goroutines at once:
if acc.Balance() >= 100 { // the check — one acquisition
    acc.Withdraw(100)     // the action — another acquisition
}
3) A RACE CONDITION WITHOUT A DATA RACE
───────────────────────────────────────
  with -race: went negative: 936 of 1000; exit code 0, reports 0

There is no data race here: not a single unsynchronised access. The error is a level higher: both goroutines checked the balance, both saw 100, both withdrew. That is a race condition: the result depends on how the goroutines happened to interleave, even though every individual access is correct. The detector has nothing to report.

It is fixed not with a flag but by making the check and the action one critical section:

GO
func (a *Account) TryWithdraw(n int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    if a.bal >= n {
        a.bal -= n
    }
}

With this method, in the practice problem's run, 0 accounts out of 1000 went negative.

One caveat about the number 936. Between the check and the withdrawal the program has runtime.Gosched() — yielding the processor so the window is visible in every run. Without it there are fewer cases, but not zero:

3) THE WINDOW WITHOUT runtime.Gosched()
───────────────────────────────────────
  with Gosched (as in the lesson):  went negative: 943 of 1000
  without Gosched, run 1:           went negative: 44 of 1000; reports 0, exit code 0
  without Gosched, run 2:           went negative: 20 of 1000; reports 0, exit code 0
  without Gosched, run 3:           went negative: 2 of 1000; reports 0, exit code 0

The window between two acquisitions exists, and someone lands in it — less often, but it is the same bug. And 943 instead of 936 in a separate run is ordinary spread.

Deeper: what -race costs and where it is turned on

measured observationbench/gorace/detector.go and practice.go, go1.24.7 linux/amd64, GOMAXPROCS=2; median of 5 runs. Time varies noticeably from run to run, memory does not.

The same workload — four goroutines and a shared map under a mutex, no races — built with -race and without:

4) COST: THE SAME WORKLOAD WITH -race AND WITHOUT
─────────────────────────────────────────────────
  series        time     -race      ratio      memory     -race      ratio
  1            177 ms    1889 ms     10.7x    6.6 MiB   32.0 MiB      4.8x
  2            159 ms    1905 ms     12.0x    6.6 MiB   32.1 MiB      4.8x
  3            242 ms    1846 ms      7.6x    6.6 MiB   33.0 MiB      5.0x

Three series in a row on one machine: time from ×7.6 to ×12.0, memory ×4.8–5.0. These numbers do not transfer to your service: there is one workload here, and another program will have a different ratio. What is stable is the direction: the mode is expensive in both memory and time. And most importantly, this is the price of the mode, not of a report: there are no races in the workload, and you pay anyway.

Hence the practice worth naming. These are recommendations, not rules of the language: frequency and coverage are chosen by the cost of a run and by how much concurrency the code has.

  • go test -race — regularly in CI. For concurrent code — on every change that touches it; in a large monorepo that may be a dedicated or nightly job. That is the detector's main home.
  • Tests where several goroutines work on shared state. The detector finds only what happened: a test where one goroutine does everything will not produce a race.
  • -race on a staging environment under load — in addition, if the tests do not reproduce real parallelism.
  • In production — not on the whole fleet. Keeping every instance under -race permanently is usually too expensive. But a limited diagnostic run — a canary instance or a staging environment with production-like load — can find what the tests never reached, if the cost and platform requirements are acceptable.

Platform requirements. -race does not work everywhere: the go command documentation lists linux/amd64, freebsd/amd64, darwin/amd64, darwin/arm64, windows/amd64, linux/ppc64le and linux/arm64 (the latter only for 48-bit VMA). On linux the detector requires cgo, and so a C compiler on the build machine:

2) -race WITHOUT cgo
────────────────────
  CGO_ENABLED=0 go build -race: go: -race requires cgo; enable cgo by setting CGO_ENABLED=1
  go build exit code: 2

The exception is macOS: according to the cmd/go source in go1.24.7, -race builds there without cgo. If a service is built with CGO_ENABLED=0 — as is common for a static binary — a run with the detector needs a separate build.

A race on the shared array of two slices, with a detector report, is covered in the article on slices: the same mechanics in a case where both goroutines write "into their own" slice.

How to answer in an interview

Short answer: a data race is when two goroutines access one memory location, at least one writes, and there is no synchronisation between the accesses. It is fixed with a mutex, a channel or an atomic operation. It is found with the -race flag: go test -race, and it should be in CI.

That is enough for a correct answer. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

On "how does it differ from a race condition" — the account example. "A data race is about individual accesses without order. A race condition is about logic: a check and an action under separate mutex acquisitions. There is no data race there, -race stays silent, and in the run the balance went negative in 936 cases out of 1000. It is fixed with one critical section."

On "will -race find every race" — three boundaries. "Only the ones that happened in this run: a race on a path that did not execute gives zero reports. Only in the execution that happened: if a lock ordered the accesses by chance, there is no race in that execution — though the program is racy. And only data races — it does not see a race condition."

On "the race is benign, the result does not matter" — torn values. "There are no benign ones. A two-word struct without synchronisation gives pairs nobody wrote — more than half the copies in the run. For slices, strings and interfaces the memory model allows memory corruption."

On a stop flag — the quote from the memory model. "A for !done {} loop is not obliged to finish. That it finished on my version of Go is an observation, not a guarantee. The correct way is atomic.Bool or a closed channel."

On "turn it on in production" — the cost and the boundary. "On my race-free workload — memory ×4.8–5.0, time ×7.6–12. So the whole fleet is not kept under -race; the main place is CI and staging, and in production — a limited diagnostic run on load the tests do not reproduce."

Next they ask

Next they ask

Is a concurrent write to a map a data race too?

Short answer

Yes, and a map has its own guard: the runtime in an ordinary build, without -race, can catch a concurrent write and crash with fatal error: concurrent map writes. But that check is best-effort, not a guarantee: two goroutines can miss each other, and then the table is simply corrupted. Details are in the lesson on maps.

Hence the rule: a concurrent map writes crash is a sign of a race, but its absence proves nothing. -race catches it more reliably — and only if the path where the map is written from several goroutines has executed.

Next they ask

Why are atomic operations not a race if the accesses are concurrent?

Short answer

Because that is how the definition is built: it excludes accesses that are all atomic. For a junior-level answer it is enough to say: an atomic operation is an indivisible action.

A senior-level answer adds that indivisibility is not the main point. Atomic operations synchronise:

If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B.

— The Go Memory Model — Atomic Values

So an atomic write of a flag, seen by an atomic read, also orders the plain writes made before it: that is what an atomic.Bool readiness flag rests on. And all the atomic operations of a program behave as though executed in one sequentially consistent order.

But atomicity protects one location. Two atomic operations in a row — "read the balance, then withdraw" — give the same race condition as two mutex acquisitions: every access is correct, the logic is not.

Next they ask

How do you do a stop flag properly?

Short answer

Three idioms, from the simplest to the richest:

  • atomic.Bool — if a flag is exactly what you need: Store(true) in one goroutine, Load() in the other's loop. The atomic operation sets the order.
  • Closing a channel — if many need to be stopped: close(stop) is seen by everyone waiting on <-stop, and the close is synchronised before a receive that returns because the channel is closed.
  • context.Context — if the stop has to travel down a call chain, with a deadline and a cause. Under the hood it is the same closed Done() channel.

A bare bool — no, under no circumstances: the memory model does not promise the loop will ever see it.

Next they ask

If the tests with -race are green, are there no races?

Short answer

No. A green run means one thing: there are no data races that showed up in these runs. The detector will not find a race in a branch the test did not execute; it will not see a race where, in this execution, a lock happened to order the accesses; and it will not find a race condition where every access is under a lock.

That is why useful tests for -race deliberately run several goroutines on shared state, and run them more than once. A test that does everything in one goroutine passes under -race just as it does without it, and proves nothing.

Common misconceptions

Claim

A data race is when the result comes out wrong

Actually

A race is accesses without order, and a wrong result is only one of the symptoms. In the run the racy program printed the correct n = 2000, and -race still found the race and made the process exit with code 66.

Claim

If the result need not be exact, the race is benign

Actually

A race breaks more than counts. A two-word struct without synchronisation handed out pairs nobody wrote — in 53–75% of copies. For slices, strings, interfaces and maps the memory model allows "arbitrary memory corruption".

Claim

A boolean stop flag needs no protection — writing one bool is atomic

Actually

The point is not the atomicity of the write but order. The memory model says of the loop for !done that it is "not guaranteed to finish". That it finished on go1.24.7 is an observation, not a guarantee.

Claim

-race finds every race in the program

Actually

Only the ones that happened in this run. One and the same binary: on the normal path — zero reports, on the rare one — a report naming the line. The documentation says plainly that it will not find races in paths that did not execute. And even on an executed path a racy program gave zero reports in 5 runs of 5: in those executions a lock happened to order the accesses.

Claim

If everything is under a mutex, there are no races

Actually

There are no data races. But a check and a withdrawal under separate acquisitions are a race condition: in the run the balance went negative in 936 cases out of 1000, and -race produced not a single report. It is fixed with one critical section for the check and the action.

Claim

-race can stay on in production, it is almost free

Actually

On the measured workload without a single race: memory ×4.8–5.0, time ×7.6–12 across series. You pay for the mode, not for a report. The main place for -race is tests, CI and staging; in production — a limited diagnostic run, not the whole fleet.

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.

Practice · predict the output

sawTorn: the writer puts equal numbers into a struct{ a, b int64 }, the reader copies it without a lock for 200 ms and checks whether it saw a != b. overdrafts(false): the balance check and the withdrawal are two separate mutex acquisitions, 1000 attempts, it prints whether an account went negative. overdrafts(true): the same through TryWithdraw. What will this code print?
func (a *Account) TryWithdraw(n int) {
a.mu.Lock()
defer a.mu.Unlock()
if a.bal >= n {
	a.bal -= n
}
}

fmt.Println(sawTorn())
fmt.Println(overdrafts(false) > 0)
fmt.Println(overdrafts(true))

Practice · estimate

A workload without a single race: four goroutines and a shared map under a mutex. How many times larger is its peak memory if it is built with -race?
times

Knowledge check

Question 1 of 5

Two goroutines each do n++ a thousand times. The program printed n = 2000. Will go run -race find a race?

Sources & further reading

4 SOURCES

  1. The Go Memory ModelOfficial documentation. The definition the topic starts from: «A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package». What breaks: «races on multiword data structures can lead to inconsistent values not corresponding to a single write» — up to «arbitrary memory corruption» for interfaces, maps, slices and strings. And on the flag loop: «The loop in main is not guaranteed to finish».https://go.dev/ref/mem
  2. Data Race DetectorOfficial documentation. Where exit code 66 comes from: «exitcode (default 66): The exit status to use when exiting after a detected race». And the boundary a green run does not cross: «The race detector only finds races that happen at runtime, so it can't find races in code paths that are not executed».https://go.dev/doc/articles/race_detector
  3. The go command — the -race flagOfficial documentation. A build flag, not a separate tool: «enable data race detection». The same place lists the platforms: «Supported only on linux/amd64, freebsd/amd64, darwin/amd64, darwin/arm64, windows/amd64, linux/ppc64le and linux/arm64 (only for 48-bit VMA)»; the lesson's runs were taken on linux/amd64.https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies
  4. The Go Memory Model — Atomic ValuesOfficial documentation. What atomic operations give beyond indivisibility: «If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B». And an order for all of them at once: «All the atomic operations executed in a program behave as though executed in some sequentially consistent order».https://go.dev/ref/mem#atomic