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.
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
Prerequisites
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 AddSince 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 timedefer 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.goA 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 +0x3cRunning 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 beforegolaunches it. - Locking a
Mutexwithout an immediatedefer Unlock()— any early return or panic betweenLock()and a manually-placedUnlock()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 -racein 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
Go Fundamentals Series
Article 10 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Goroutines and Channels in Go: A Fresher-Friendly Guide
A beginner's guide to Go concurrency: what a goroutine actually is, every channel type (unbuffered, buffered, directional), select, and a worker pool example that ties it all together — with diagrams.
Context in Go: Cancellation, Deadlines and Request Propagation
Understand Go's context.Context: cancellation, deadlines, timeouts and request-scoped propagation, with practical HTTP and database examples.
Advanced Go Concurrency: Worker Pools, Backpressure and Bounded Concurrency
Why unbounded goroutines don't scale: semaphore-based bounded concurrency, backpressure, errgroup, goroutine leaks, and a real benchmark comparing the two.