Skip to content
GoBeginner7 min read

sync.WaitGroup, sync.Mutex, and the Race Detector in Go

How to wait for a group of goroutines to finish with WaitGroup, protect shared state with Mutex when a channel is overkill, and catch data races before they reach production with go test -race — with diagrams.

GoBackend.dev
GoConcurrencysyncMutexTesting

TL;DR

sync.WaitGroup counts down to zero as goroutines finish so the caller knows when to stop waiting; sync.Mutex locks a critical section so only one goroutine touches shared state at a time; and go test -race instruments your code to catch the exact bugs that happen when either discipline is skipped.

What You'll Learn

  • How WaitGroup's Add, Done, and Wait actually coordinate goroutines
  • The classic WaitGroup bug: calling Add from inside the goroutine instead of before it starts
  • What a data race actually is, and why it's undefined behavior, not just 'probably fine'
  • How sync.Mutex protects a critical section, and why you must always pair Lock with defer Unlock
  • When to reach for a channel and when a plain Mutex is the simpler, more honest tool
  • How to run the race detector and read what it reports

The problem

var total int
 
func add(n int) {
	total += n // read, modify, write — not one atomic step
}
 
func main() {
	for i := 0; i < 1000; i++ {
		go add(1)
	}
	time.Sleep(time.Second) // "wait" for them all — and already a bad idea
	fmt.Println(total) // rarely exactly 1000
}

Two separate bugs are stacked here: time.Sleep is a guess at how long 1000 goroutines might take, not a guarantee they've finished, and total += n is not one atomic operation — it's a read, an addition, and a write, and two goroutines can interleave those three steps and lose an update. sync.WaitGroup fixes the first problem; sync.Mutex fixes the second.

sync.WaitGroup: waiting for N goroutines to finish

A WaitGroup is a counter with three operations: Add(n) increases it, Done() decreases it by one, and Wait() blocks until it reaches zero.

var wg sync.WaitGroup
 
for i := 0; i < 3; i++ {
	wg.Add(1) // increment before launching — not inside the goroutine
	go func(id int) {
		defer wg.Done() // always decrement, even if the goroutine panics
		fmt.Println("worker", id, "done")
	}(i)
}
 
wg.Wait() // blocks until all 3 have called Done()
fmt.Println("all workers finished")

Rendering diagram…

The classic bug is calling wg.Add(1) inside the goroutine instead of before go launches it:

for i := 0; i < 3; i++ {
	go func(id int) {
		wg.Add(1) // WRONG — Wait() might already be running by the time this executes
		defer wg.Done()
		// ...
	}(i)
}
wg.Wait() // can return before every goroutine has even called Add

Since goroutine scheduling isn't guaranteed to start immediately, Wait() can observe a counter of zero (nothing has Added yet) and return instantly, before any of the actual work has even begun. Always call Add in the loop that launches the goroutines, never from inside one.

What a data race actually is

A data race is two goroutines accessing the same memory location concurrently, where at least one of them is a write, with no synchronization ordering one access before the other. The Go memory model's answer to "what happens when a race occurs" is blunt: it's undefined behavior — not "probably fine," not "just a slightly wrong number." The compiler is free to reorder or cache reads and writes in ways that are only safe in the absence of a race, so a racy program can behave inconsistently across machines, Go versions, or even repeated runs on the same machine.

total += n from the opening example, called from many goroutines with no synchronization, is exactly this: a race on total, and its final value is not a "slightly off" number you can reason about — it's genuinely undefined.

sync.Mutex: one goroutine in the critical section at a time

A Mutex (mutual exclusion lock) guarantees that only one goroutine at a time executes the code between Lock() and Unlock() — the critical section:

var (
	mu    sync.Mutex
	total int
)
 
func add(n int) {
	mu.Lock()
	defer mu.Unlock() // always pair Lock with a deferred Unlock
	total += n         // now safe — only one goroutine here at a time
}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		add(1)
	}()
}
wg.Wait()
fmt.Println(total) // exactly 1000, every time

defer mu.Unlock() immediately after Lock() is the pattern to default to without exception — it guarantees the lock releases on every return path, including an early return or a panic, the same reasoning already covered in Defer, Panic, and Recover. A Mutex that gets locked but never unlocked deadlocks every future caller of Lock(), permanently.

For the common case of many readers and occasional writers, sync.RWMutex adds RLock()/RUnlock(), letting any number of readers hold the lock simultaneously as long as no writer holds it — useful when reads vastly outnumber writes and blocking every reader behind a plain Mutex would be wasteful.

Channel or Mutex?

Both solve the same underlying problem — safe access to shared state from multiple goroutines — from different angles, and Go's own advice ("share memory by communicating") doesn't mean a Mutex is wrong, just that it's not the only tool:

  • Prefer a channel when the goroutines are handing off ownership of a value — a job queue, a pipeline stage, a result being sent back — which is the shape covered in Goroutines and Channels in Go.
  • Prefer a Mutex when several goroutines need to keep reading and writing the same piece of shared state in place — an in-memory cache, a counter, a connection pool's bookkeeping — where modeling it as values flowing through a channel would be more contorted than just protecting the state directly.

Neither is a "beginner" or "advanced" tool — real Go services use both, choosing per situation.

Catching races with go test -race

Bugs from missing synchronization frequently don't show up in a normal run — the timing has to line up exactly wrong, which can take thousands of iterations or never happen on your machine at all while still happening in production. The race detector instruments every memory access at compile time and reports the exact conflicting accesses, even ones that didn't actually corrupt anything on this particular run:

go test -race ./...
go run -race main.go

A detected race looks like this — two goroutines, one call stack for each side of the conflicting access, pinpointing the exact line:

WARNING: DATA RACE
Write at 0x00c0000140a0 by goroutine 8:
  main.add()
      /app/main.go:12 +0x3c
 
Previous write at 0x00c0000140a0 by goroutine 7:
  main.add()
      /app/main.go:12 +0x3c

Running the race detector in CI on every commit — not just occasionally by hand — is the practical way to catch this class of bug before it reaches production, since a race can pass a normal test run cleanly and still be live in the code.

Common mistakes

  • Calling wg.Add() inside the goroutine it's counting, instead of before go launches it.
  • Locking a Mutex without an immediate defer Unlock() — any early return or panic between Lock() and a manually-placed Unlock() leaves the lock held forever.
  • Assuming a race that "doesn't show up in testing" doesn't exist. Data races are timing-dependent by nature — absence of symptoms on your machine proves nothing.
  • Never running go test -race in CI, so races are only found the hard way, in production.

Summary

WaitGroup counts goroutines down to zero so the caller knows exactly when they've all finished — as long as Add happens before they're launched, not inside them. A data race — concurrent access to the same memory with at least one write and no synchronization — is undefined behavior, and sync.Mutex fixes it by guaranteeing only one goroutine executes a critical section at a time, provided Lock is always paired with an immediate defer Unlock. Channels and mutexes solve overlapping problems from different angles; the real tool for catching the bugs either one prevents is go test -race, run as a routine part of CI, not as a one-off debugging step.

Key Takeaways

  • wg.Add(n) must happen before the goroutines it counts are launched, never inside them — otherwise Wait can return before every goroutine has even started
  • A data race is two goroutines accessing the same memory concurrently, with at least one write, and no synchronization between them — its behavior is officially undefined, not just occasionally wrong
  • sync.Mutex.Lock() must always be paired with defer Unlock() immediately after, so every return path releases it
  • Prefer a channel when you're handing off ownership of a value; prefer a Mutex when several goroutines need to keep reading and writing the same shared state in place
  • go test -race (or go run -race) instruments every memory access and reports races that a normal run can go thousands of iterations without ever revealing