sync.WaitGroup
A goroutine runs independently of the code that launched it, so the launching function has no built-in way to know when that goroutine finishes. sync.WaitGroup solves exactly this problem: it is a counter that lets one goroutine block until a whole group of other goroutines has finished its work. It is one of the most common concurrency primitives in Go, showing up anywhere you fan work out across multiple goroutines and need to know when it is safe to move on — before reading results, closing a channel, or letting main return.
Overview: How WaitGroup Works
A sync.WaitGroup is, at heart, an internal counter plus a way to park and wake goroutines. Its zero value is ready to use — you never call a constructor, just declare var wg sync.WaitGroup and start calling its three methods:
wg.Add(n)— atomically addsnto the counter. You call this once for each unit of work, before starting the goroutine that will perform it.wg.Done()— atomically subtracts 1 from the counter. It is literally implemented asAdd(-1), and is normally called withdeferas the first line inside the goroutine.wg.Wait()— blocks the calling goroutine until the counter reaches zero.
Internally, the counter and a count of waiting goroutines are packed together and manipulated with atomic operations, so Add and Done are safe to call concurrently from many goroutines without any extra locking. When Wait() is called and the counter is already zero, it returns immediately. If the counter is greater than zero, the calling goroutine is parked using a runtime semaphore — it costs no CPU while blocked, it is not spinning in a loop. When the counter drops to zero (the last Done() call), the runtime wakes every goroutine parked in Wait(). If Add ever drives the counter below zero — for example, calling Done() more times than Add() — the program panics with sync: negative WaitGroup counter.
A crucial detail: WaitGroup carries no payload. It only tells you “N things finished”, never what those things produced. To collect actual results you combine it with a channel, a mutex-protected structure, or (as in the first example below) by having each goroutine write to its own slice index. It is also important that a WaitGroup is never copied after it has been used — copying it duplicates the internal counter, so the copy and the original stop being in sync. Because of this, functions that accept a WaitGroup almost always take it as a *sync.WaitGroup pointer, and go vet‘s copylocks check will flag an accidental value copy.
Syntax
The general shape of every WaitGroup-based fan-out looks like this:
var wg sync.WaitGroup
wg.Add(1) // increment the counter before starting the goroutine
go func() {
defer wg.Done() // decrement the counter when this goroutine returns
// ... do work ...
}()
wg.Wait() // blocks until the counter reaches 0
| Method | Effect |
|---|---|
Add(delta int) |
Adds delta (can be negative) to the internal counter, atomically. |
Done() |
Shorthand for Add(-1); call once per goroutine when it finishes. |
Wait() |
Blocks the caller until the counter is 0; returns immediately if it already is. |
Examples
Example 1: Collecting results into a slice
Each goroutine below writes to its own index of a pre-sized slice, so there is no data race even though the writes happen concurrently — every goroutine touches a different memory location.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]int, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = i * i
}(i)
}
wg.Wait()
fmt.Println("Squares:", results)
}
Output:
Squares: [0 1 4 9 16]
Notice that i is passed as a parameter to the goroutine's function literal rather than referenced directly from the enclosing scope. This makes each goroutine capture its own copy of the loop index, which is the portable, defensive way to avoid the classic loop-variable-capture bug (Go 1.22+ actually gives each iteration its own i automatically, but writing it explicitly still works on every supported version and makes the intent obvious).
Example 2: A worker pool
This is the pattern you will see most often in real code: a fixed number of worker goroutines pull jobs off a channel, and a WaitGroup tracks when every worker has exited so the results channel can be closed safely.
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * j
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
sum := 0
for r := range results {
sum += r
}
fmt.Println("Sum of squares:", sum)
}
Output:
Sum of squares: 55
wg is passed as *sync.WaitGroup so all three workers share the same counter instead of each getting its own copy. Because closing results too early would panic any worker still trying to send, the code launches a separate goroutine that waits for all workers to finish and only then closes the channel — main can safely keep draining results with range until that close happens.
Example 3: Wait() actually blocks
This example makes the blocking behavior visible: the message after wg.Wait() can only print once every goroutine has finished sleeping.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(time.Duration(id) * 10 * time.Millisecond)
}(i)
}
fmt.Println("Waiting for goroutines to finish...")
wg.Wait()
fmt.Println("All goroutines finished")
}
Output:
Waiting for goroutines to finish...
All goroutines finished
Only main prints in this example, so the output is deterministic even though the three goroutines finish at different times — the second line is guaranteed to appear only after the slowest of the three sleeps has completed.
How It Works Step by Step
Walking through Example 3: (1) var wg sync.WaitGroup creates a counter starting at 0. (2) Each wg.Add(1) runs in main, atomically incrementing the counter to 3 in total, before the corresponding goroutine is even started — this ordering guarantees the increment happens-before any later Wait() call observes it. (3) Each go func(id int) {...}(i) hands a new goroutine to the scheduler, which may run it immediately or later, possibly on a different OS thread. (4) main calls wg.Wait(); since the counter is 3 (greater than zero), main parks instead of busy-looping. (5) As each goroutine finishes sleeping, its deferred wg.Done() runs, atomically decrementing the counter. (6) When the third and final Done() brings the counter to 0, the runtime wakes the parked Wait() call. (7) Wait() returns and main resumes, printing the final line. If any goroutine panics before its deferred Done() runs, the panic still triggers the deferred call during unwinding (unless the whole process crashes first), which is one reason to always put defer wg.Done() as the very first statement.
Common Mistakes
1. Calling Add() inside the goroutine
Add must run in the goroutine that starts the work, before the go statement — never inside the new goroutine itself. Otherwise Wait() can race ahead and return before any Add() has even executed, because the scheduler is free to run main's Wait() call before any of the new goroutines get a turn.
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // WRONG: Add races with the Wait() call below
defer wg.Done()
// ... do work ...
}()
}
wg.Wait() // may return immediately, before any Add() has run
Fix: call Add(1) in the loop, before launching each goroutine.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
}()
}
wg.Wait()
fmt.Println("all workers finished")
}
Output:
all workers finished
2. Forgetting to call Done()
If a goroutine returns without ever calling wg.Done(), the counter never reaches zero and wg.Wait() blocks forever — a deadlock. This is especially easy to introduce when an early return inside the goroutine skips a non-deferred Done() call.
var wg sync.WaitGroup
wg.Add(1)
go func() {
fmt.Println("working")
// forgot to call wg.Done()
}()
wg.Wait() // blocks forever: the counter never reaches 0
fmt.Println("done")
Fix: always defer wg.Done() as the first line of the goroutine, so it runs no matter how the function exits.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("working")
}()
wg.Wait()
fmt.Println("done")
}
Output:
working
done
3. Copying a WaitGroup by value
A sync.WaitGroup holds internal state that must never be duplicated. Passing it by value into a function copies that state, so the copy inside the function no longer shares a counter with the original — Done() calls on the copy never satisfy a Wait() on the original.
func startWorker(wg sync.WaitGroup) { // WRONG: copies the WaitGroup by value
defer wg.Done()
// ... do work ...
}
Fix: always pass a pointer, as Example 2's worker function does.
func startWorker(wg *sync.WaitGroup) { // correct: shares the same WaitGroup via a pointer
defer wg.Done()
// ... do work ...
}
Best Practices
- Call
wg.Add()before starting the goroutine it counts for, never inside that goroutine. - Put
defer wg.Done()as the very first line inside the goroutine so it always runs, even on early returns or panics. - Never copy a
sync.WaitGroupafter it has been used; pass it around as*sync.WaitGroup. - Use one
WaitGroupper batch of goroutines. Reusing aWaitGroupfor a second batch is fine only afterWait()has returned for the first batch — otherwise newAddcalls can race with the in-flightWait. - Remember that
WaitGroupcarries no data. If goroutines need to return values, pair it with a channel or a mutex-protected slice/map, or write into distinct indices as in Example 1. - Never call
wg.Wait()from inside one of the goroutines it is waiting on — that goroutine can never callDone()after blocking, which deadlocks. - When goroutines can also fail, consider
golang.org/x/sync/errgroup, which builds on the same idea but also propagates the first error and supports cancellation.
Practice Exercises
- Write a program that launches 5 goroutines, each computing the cube of its own index, storing the result at that index of a pre-sized slice, and prints the slice after
wg.Wait(). Expected output:Cubes: [0 1 8 27 64]. - Modify the worker-pool example so it uses 4 workers and processes jobs
1through10, printing the sum of their squares. (Hint: the sum of squares from 1 to 10 is 385.) - Take the “forgot Done()” mistake example, add the missing
defer wg.Done(), then intentionally remove it again and think through how you would notice the deadlock in a real program (a request that never returns, a CLI tool that hangs,go run -race, or dumping goroutine stacks with a `SIGQUIT`).
Summary
sync.WaitGrouplets one goroutine block until a group of other goroutines has finished.- Its zero value is ready to use; call
Addbefore starting each goroutine,Donewhen it finishes, andWaitto block until the counter reaches zero. Done()is justAdd(-1); if the counter ever goes negative, the program panics.AddandDoneare safe to call concurrently;Waitparks efficiently instead of busy-waiting.- Never copy a
WaitGroupafter use — pass it as a pointer. WaitGroupcarries no data of its own — combine it with channels or a mutex to collect results safely.- A missing
Done()call causes a deadlock; callingAdd()inside the goroutine it counts for causes a race withWait().
