Race Conditions

A race condition happens when two or more goroutines access the same piece of memory at the same time, at least one of them is writing, and there is no synchronization to order those accesses. Go makes it trivially easy to launch goroutines with the go keyword, but it does nothing to stop them from stepping on each other’s toes. The result is a program that usually looks correct, sometimes gives the wrong answer, and occasionally crashes outright — and because the bug depends on scheduling timing, it can hide in your test suite for months and then appear the moment the code runs on a busier server.

Overview: What a Race Condition Actually Is

The Go memory model defines a data race precisely: it occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write, with no happens-before relationship connecting them. “Happens-before” is the ordering guarantee that synchronization primitives (channels, sync.Mutex, sync.WaitGroup, the sync/atomic package) establish between goroutines. Without one of these, the Go runtime, the CPU, and the compiler are all free to reorder, cache, or interleave memory operations however they like, because from a single goroutine’s point of view nothing looks wrong.

This matters more than it might seem. A statement as small as counter++ is not one atomic operation — it compiles down to three separate steps: read counter from memory into a register, add one to it, and write the new value back. If goroutine A and goroutine B both perform these three steps on the same variable at almost the same time, one goroutine’s write can be silently overwritten by the other’s, and an increment is lost. Multiply that by thousands of goroutines and the final result drifts further and further from what you expect.

It gets worse than “just” wrong numbers. Concurrent writes to a Go map are actively detected by the runtime and cause an immediate, unrecoverable crash (fatal error: concurrent map writes). And because modern CPUs use per-core caches and compilers are allowed to keep a variable in a register instead of re-reading it from memory, a goroutine spinning on an unsynchronized flag set by another goroutine may never observe the update at all, hanging forever. A race condition is undefined behavior in the Go memory model — there is no “safe” outcome to rely on, even if a particular build happens to work today.

The fix is always the same idea in different clothes: give the compiler and runtime an explicit ordering guarantee. Go gives you three main tools for that: sync.Mutex/sync.RWMutex to serialize access to shared state, the sync/atomic package for lock-free operations on single values, and channels to pass ownership of data between goroutines instead of sharing it. The idiomatic Go proverb captures the philosophy: “Don’t communicate by sharing memory; share memory by communicating.”

Syntax

There is no special syntax for a race condition itself — it is a bug pattern, not a language construct. What you need is fluency with the tools that prevent it:

Tool Typical usage Purpose
sync.Mutex var mu sync.Mutex
mu.Lock()mu.Unlock()
Only one goroutine may run the code between Lock and Unlock at a time
sync.RWMutex mu.RLock()/RUnlock() for reads, mu.Lock()/Unlock() for writes Allows many concurrent readers, but only one exclusive writer
sync/atomic var c atomic.Int64
c.Add(1), c.Load(), c.Store(v)
Lock-free, atomic read/modify/write on a single value
channels ch := make(chan T)
ch <- v / v := <-ch
Hands data (and its ownership) from one goroutine to another safely
race detector go run -race main.go
go test -race ./...
Instruments the binary to catch data races at runtime

Examples

Example 1: A Racy Counter

package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var wg sync.WaitGroup

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

	wg.Wait()
	fmt.Println("Expected:", 1000)
	fmt.Println("Actual (racy):", counter)
}

Output:

Expected: 1000
Actual (racy): 1000

This program launches 1000 goroutines that each execute counter++ with no synchronization at all. The sync.WaitGroup correctly waits until every goroutine has finished, but it says nothing about whether their reads and writes to counter were ordered safely. Because counter++ is really “read, add, write”, two goroutines can both read the same value before either writes back, and one increment gets lost. The output above shows one possible run; in practice the second line is unpredictable and will very often print a number smaller than 1000 — that is the race condition in action, and it is also exactly the kind of bug that the Go compiler will never warn you about, because the code is perfectly valid Go.

Example 2: Fixing It with a Mutex

package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			counter++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println("Final counter:", counter)
}

Output:

Final counter: 1000

Wrapping the read-modify-write sequence in mu.Lock()/mu.Unlock() guarantees that only one goroutine can execute counter++ at a time. Every other goroutine trying to lock the mutex simply blocks until it is released. This establishes the happens-before relationship that was missing before, so the final value is deterministic every single time you run it.

Example 3: Fixing It with sync/atomic

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter atomic.Int64
	var wg sync.WaitGroup

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

	wg.Wait()
	fmt.Println("Final counter:", counter.Load())
}

Output:

Final counter: 1000

For a single value like a counter, a full mutex is often more machinery than you need. The sync/atomic package (using the typed atomic.Int64, atomic.Bool, and similar wrappers introduced in Go 1.19) performs the read-modify-write as one indivisible hardware-level operation, with no possibility of two goroutines interleaving mid-increment. Atomics are typically cheaper than a mutex for simple counters, but they only protect a single value at a time — if you need to update several related fields together, reach for a mutex instead.

How the Race Detector Works

You should never rely on eyeballing code to spot races — use the tool Go ships for exactly this. Passing -race to go build, go run, or go test recompiles your program with extra instrumentation that tracks every memory access and every synchronization event using a variant of the “happens-before” algorithm (based on vector clocks). At runtime, if it observes two accesses to the same memory location, from different goroutines, at least one a write, with no happens-before edge between them, it immediately reports a data race with a full stack trace of both accesses. Running the racy Example 1 through the detector looks like this:

$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c000014078 by goroutine 8:
  main.main.func1()
      /tmp/main.go:15 +0x44

Previous write at 0x00c000014078 by goroutine 7:
  main.main.func1()
      /tmp/main.go:15 +0x44

Goroutine 8 (running) created at:
  main.main()
      /tmp/main.go:13 +0x9c
==================
Expected: 1000
Actual (racy): 998
Found 1 data race(s)
exit status 66

Notice that the program still runs to completion and prints output — the race detector reports the problem, it does not stop the corruption from happening. The detector adds real CPU and memory overhead (roughly 2–10x slower, several times more memory), so it is a development and CI tool, not something you ship in a production binary. The right habit is to run your test suite with go test -race ./... in CI on every commit so races are caught before they reach production.

Common Mistakes

Mistake 1: Writing to a Map from Multiple Goroutines

Go’s built-in map type is not safe for concurrent writes. Unlike a slice, the runtime actively watches for this and crashes the whole program rather than silently corrupting data:

package main

import (
	"fmt"
	"sync"
)

func main() {
	m := make(map[int]int)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			m[i] = i * i
		}(i)
	}

	wg.Wait()
	fmt.Println(len(m))
}

This compiles without complaint, but every goroutine is writing to the same map at once with no coordination. Under load this reliably crashes with fatal error: concurrent map writes, which even recover() cannot catch because it is a fatal runtime error, not a panic. The fix is to serialize the writes with a mutex (or use sync.Map, which is built for exactly this pattern):

package main

import (
	"fmt"
	"sync"
)

func main() {
	m := make(map[int]int)
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			mu.Lock()
			m[i] = i * i
			mu.Unlock()
		}(i)
	}

	wg.Wait()
	fmt.Println("Map size:", len(m))
}

Output:

Map size: 100

Mistake 2: Signaling Completion with a Plain Boolean

It is tempting to let one goroutine set a bool and have another spin-wait on it, but without synchronization there is no guarantee the reading goroutine ever sees the write:

package main

import (
	"fmt"
	"time"
)

func main() {
	done := false

	go func() {
		time.Sleep(100 * time.Millisecond)
		done = true
	}()

	for !done {
		// busy-wait with no synchronization: this is a data race
	}

	fmt.Println("Goroutine finished")
}

This is a textbook data race on done: one goroutine writes it, the main goroutine reads it in a tight loop, and nothing establishes a happens-before edge between them. The compiler is technically free to decide the loop never needs to re-read done from memory, so this can hang forever on some builds even though it “usually” works. Replace the shared flag with a channel, which is both safe and idiomatic:

package main

import (
	"fmt"
	"time"
)

func main() {
	done := make(chan struct{})

	go func() {
		time.Sleep(100 * time.Millisecond)
		close(done)
	}()

	<-done
	fmt.Println("Goroutine finished")
}

Output:

Goroutine finished

Closing a channel is itself a synchronization event: every receive from a closed channel happens-after the close, so the main goroutine is guaranteed to observe it and unblock exactly once, safely.

Best Practices

  • Run go test -race ./... in CI on every build — the race detector cannot find a race that never executes, but it is extremely effective at catching the ones your tests do exercise.
  • Prefer channels for handing off data and signaling between goroutines; reach for sync.Mutex or sync/atomic when you truly need to share mutable state in place.
  • Keep critical sections (the code between Lock and Unlock) as small as possible — the longer you hold a lock, the more goroutines pile up waiting on it.
  • Use sync/atomic‘s typed wrappers (atomic.Int64, atomic.Bool, etc.) for single counters and flags instead of a full mutex; they are cheaper and harder to misuse than the older pointer-based atomic functions.
  • Never copy a sync.Mutex or a struct that embeds one after first use — copying a mutex breaks its internal state. go vet catches this for you.
  • Always pair every goroutine with an explicit way to know it is done, such as a sync.WaitGroup or a channel close — goroutines with no exit signal are the classic cause of goroutine leaks.
  • Be careful with closures over loop variables inside goroutines; in Go versions before 1.22 the loop variable was shared across iterations, so pass it as an explicit parameter (go func(i int) { ... }(i)) to be safe on any supported version.

Practice Exercises

  • Take the racy counter from Example 1, run it several times with go run main.go (no -race) and note that the final value sometimes differs from 1000. Then run it with go run -race main.go and read the report it produces.
  • Write a program where 10 goroutines each append their goroutine number to a shared []int slice with no synchronization. Predict what could go wrong (hint: slices are not safe for concurrent writes either, since append can reallocate the underlying array), then fix it with a sync.Mutex.
  • Rewrite the busy-wait example from Mistake 2 so that instead of one signal, a producer goroutine sends 5 integers one at a time over a channel and the main goroutine prints each one as it arrives, then prints "done" after the channel is closed.

Summary

  • A data race is two goroutines accessing the same memory concurrently, with at least one write and no happens-before ordering between them — its behavior is undefined, not just “sometimes wrong”.
  • counter++ is not atomic; it is a read, an add, and a write, which is exactly why unsynchronized increments lose updates.
  • Concurrent writes to a Go map crash the program with fatal error: concurrent map writes; concurrent access to a plain variable can silently corrupt data or hang forever.
  • sync.Mutex/sync.RWMutex serialize access to shared state; sync/atomic gives lock-free operations on single values; channels hand data off between goroutines without sharing memory at all.
  • The race detector (-race flag on go build/go run/go test) instruments your binary to catch races at runtime and should run in CI on every commit.
  • Always give every goroutine you launch an explicit, synchronized way to finish and be waited on.