WaitGroup and the Fan-Out: Counting Goroutines to Zero
sync.WaitGroup for waiting on a group of goroutines, why you pass it by pointer and how go vet catches a copy, the loop-variable capture bug that Go 1.22 deleted, and the WaitGroup.Go helper added in Go 1.25. Compiled and run against Go 1.26.5.
You start a goroutine and main moves on without it. That’s the whole point of go, and it’s also the first problem you hit: if main returns while your goroutines are still working, the program exits and takes them with it, half-finished. You need a way to say “launch these N pieces of work, then wait here until all N are done.” Channels can do it, but for the specific job of counting goroutines to completion the standard library has a purpose-built tool: sync.WaitGroup.
A WaitGroup is a concurrency-safe counter with three methods. Add(n) raises the count by n, Done lowers it by one, and Wait blocks until the count reaches zero. The pattern is fan-out: bump the counter once per goroutine you launch, have each goroutine call Done as it finishes, and Wait in the launcher until the last one drops the count to zero.
The basic shape
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for _, name := range []string{"alice", "bob", "carol"} {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("greeting", name)
}()
}
wg.Wait()
fmt.Println("all greetings done")
}
greeting carol
greeting bob
greeting alice
all greetings done
Three details carry the weight. The zero value of a WaitGroup is ready to use, so var wg sync.WaitGroup needs no initialization. Add(1) runs in the launching goroutine before the go statement, never inside the new goroutine, because otherwise Wait could run before the goroutine has scheduled and see a count of zero. And Done is deferred as the very first line of the goroutine, so it fires no matter how the function exits, even on a panic-and-recover. The order of the greetings changes from run to run, which is the goroutines being scheduled independently, but all greetings done always prints last because Wait holds main until the count hits zero.
Pass it by pointer, and let vet prove it
A WaitGroup must never be copied after first use. Its methods coordinate through internal state, and a copy is a separate counter: the goroutine calling Done on the copy decrements a different number than the one Wait is watching, so Wait blocks forever or the program races. This bites most often when you pass a WaitGroup to a function by value, which copies it:
// worker takes a WaitGroup BY VALUE — this copies it, which is a bug.
func worker(wg sync.WaitGroup, id int) {
defer wg.Done()
fmt.Println("worker", id)
}
You do not have to remember this rule, because the toolchain remembers it for you. WaitGroup embeds a noCopy marker that go vet knows to look for, and running vet on that code reports:
worker passes lock by value: sync.WaitGroup contains sync.noCopy
call of worker copies lock value: sync.WaitGroup contains sync.noCopy
Two findings, exit status 1: one for the parameter that receives a copy, one for the call site that makes it. The fix is to pass *sync.WaitGroup instead, so every reference points at the one counter. This is worth wiring into CI precisely because the bug is invisible at runtime until it deadlocks. Vet catches it statically, before the program ever runs.
The counter can’t go negative
The counter has one hard invariant: it must never drop below zero. Call Done more times than you called Add and the runtime doesn’t quietly ignore it, it panics. This snippet does one Add(1) and two Done calls:
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done() // one Done too many
Running it aborts with panic: sync: negative WaitGroup counter. That’s the runtime telling you your bookkeeping is wrong, and it’s a genuinely useful failure: a mismatch between Add and Done almost always means a goroutine you thought you were waiting for either wasn’t counted or got counted twice. The panic surfaces the accounting error loudly instead of letting Wait return early and your program proceed on the false belief that everything finished. It’s also a large part of why WaitGroup.Go, below, is worth preferring: when the library owns the Add/Done pairing, you can’t get the pairing wrong.
The loop variable bug that no longer exists
If you’ve read older Go tutorials, you’ve seen a warning attached to exactly the loop above: the closure captures the loop variable, all the goroutines share one variable, and by the time they run the loop has finished and they all see its final value. The standard fix was to shadow it with i := i at the top of the loop body, or to pass it in as an argument to the goroutine.
As of Go 1.22, that bug is gone. The language changed so that a loop variable is a fresh variable per iteration, not one variable reused across all of them. Each goroutine now closes over its own copy. On Go 1.26.5 you can prove it: launch a goroutine per iteration that records the loop index, with no i := i and no parameter, and check what they captured.
package main
import (
"fmt"
"sort"
"sync"
)
func main() {
var wg sync.WaitGroup
seen := make([]int, 0, 5)
var mu sync.Mutex
for i := range 5 {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
seen = append(seen, i) // captured directly — no i := i
mu.Unlock()
}()
}
wg.Wait()
sort.Ints(seen)
fmt.Println(seen)
}
Run five times, this printed [0 1 2 3 4] every time (and clean under go run -race). Five distinct values, one per iteration. Under the pre-1.22 rule this would have printed [4 4 4 4 4] or similar, every goroutine seeing the same final i. So when you meet a tutorial that religiously writes i := i as the first line of every ranged loop, you’re looking at a reflex guarding against a bug the language deleted. It’s harmless, just no longer necessary. (The mutex here is not about the loop variable at all; it guards the concurrent append to the shared slice, which is genuinely unsafe without it. That’s the subject of the next chapter.)
WaitGroup.Go: the bookkeeping, built in
The Add(1) / go / defer wg.Done() trio is so routine that Go 1.25 added a helper that does all three for you. WaitGroup.Go(f) increments the counter, launches f in a new goroutine, and arranges the Done when f returns. You hand it a function; it handles the counting.
Here’s a real fan-out written with it: fetch a handful of endpoints concurrently and collect the results, waiting for all of them before printing.
package main
import (
"fmt"
"sync"
)
func fetch(url string) string {
return "200 OK from " + url
}
func main() {
urls := []string{"catalog", "orders", "web", "payments", "search"}
var wg sync.WaitGroup
results := make([]string, len(urls))
for i, url := range urls {
wg.Go(func() {
results[i] = fetch(url)
})
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
}
200 OK from catalog
200 OK from orders
200 OK from web
200 OK from payments
200 OK from search
Verified on Go 1.26.5, and clean under -race. Notice there’s no Add, no Done, no defer bookkeeping to get wrong. Notice too that each goroutine writes to results[i], a distinct slot in a pre-sized slice, so there’s no shared write to coordinate. That’s the safe way to collect results from a fan-out: give each goroutine its own destination and read the slice only after Wait returns. If instead every goroutine appended to one shared slice, you’d need a mutex, exactly as the loop-variable example did. The -race run stayed clean here because the writes never overlap.
Final thoughts
sync.WaitGroup is the counter you use to wait for a group of goroutines to finish: Add before you launch, Done as each completes, Wait to block until the count is zero. Pass it by pointer, and let go vet catch the copy for you. Two modern facts are worth carrying: the loop-variable capture bug was fixed in Go 1.22, so per-iteration closures now see per-iteration values and the old i := i guard is obsolete; and WaitGroup.Go, added in Go 1.25, folds the whole Add/launch/Done dance into one call. Fan out to distinct result slots and you can collect answers with no further synchronization. But the moment two goroutines touch the same memory, you’re in different territory, and the next chapter is about the tool that finds those bugs and the mutex that fixes them.
Next: mutexes and the race detector — the counter that comes out wrong, and the flag that tells you why.
Comments