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.
- 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 onsyncandatomic; - what happens-before is, at least roughly — from the same lesson.
- 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
-racereport, what it does not find and what it costs.
What is really being asked
The ladder is almost always this one:
- "What is a data race?" — "when a counter loses increments" is incomplete, and that is where candidates get caught.
- "How does a data race differ from a race condition?" — the question the topic exists for.
- "How do you find a race?" —
-race, and that is half the answer. - "Will
-racefind every race?" — no, and you need to be able to say which ones it will not find. - "Can you turn on
-racein production?" — about the cost. - "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.
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
syncandatomic). - "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:
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.
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:
var done bool
go func() {
for !done {
}
// ...
}()
done = trueThe 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.
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
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.
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:
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:
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
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.
-raceon 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
-racepermanently 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
Is a concurrent write to a map a data race too?
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.
Why are atomic operations not a race if the accesses are concurrent?
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.
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.
How do you do a stop flag properly?
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 closedDone()channel.
A bare bool — no, under no circumstances: the memory model does not promise
the loop will ever see it.
If the tests with -race are green, are there no races?
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
A data race is when the result comes out wrong
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.
If the result need not be exact, the race is benign
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".
A boolean stop flag needs no protection — writing one bool is atomic
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.
-race finds every race in the program
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.
If everything is under a mutex, there are no races
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.
-race can stay on in production, it is almost free
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
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
Knowledge check
Two goroutines each do n++ a thousand times. The program printed n = 2000. Will go run -race find a race?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- 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
-racedetector looks not for a wrong answer but for unordered accesses: a race after which the program printed the correctn = 2000was 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.
In fact
- 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-racestill found the race and made the process exit with code 66. - 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".
- 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. - 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.
- 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
-raceproduced not a single report. It is fixed with one critical section for the check and the action. - 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
-raceis tests, CI and staging; in production — a limited diagnostic run, not the whole fleet.
What is covered
- What is really being asked
- Base: a definition without the word "result"
- Mechanism 1: a value nobody wrote
- Mechanism 2: a flag that may never arrive
- Mechanism 3: what `-race` sees — order, not result
- Mechanism 4: what `-race` does not see
- Deeper: what `-race` costs and where it is turned on
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
4 SOURCES
- 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
- Data Race DetectorOfficial documentation. Where exit code 66 comes from: «
exitcode(default66): 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 - 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
- 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