Why wg.Add Inside the Goroutine Lets Wait Return Early
This program looks fine, passes most runs, and occasionally prints nothing at all. The bug is one line in the wrong place: wg.Add(1) sits inside the goroutine it is supposed to be counting.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // wrong: runs whenever the scheduler feels like it
defer wg.Done()
fmt.Println("worker", i)
}()
}
wg.Wait()
}
Run it a few times. Sometimes you get five lines, sometimes two, sometimes none. Nothing panics and nothing is reported as an error. Wait simply returned, and main exited with the workers still on their way.
What Wait actually checks
Wait blocks until the counter is zero. It does not know how many goroutines you intend to start; it only knows what the counter says at the moment it looks. A fresh WaitGroup has a counter of zero.
The go statement only schedules the new goroutine. It does not run it. So the main goroutine can race through the loop, reach wg.Wait(), see zero, and carry on, all before any worker has executed its first line.
The counter must be raised before the goroutine can possibly run, and that is only guaranteed if the caller does it, synchronously, before the go statement.
The interleaving, step by step
- Main starts goroutine 1. It is runnable but has not executed anything.
- Main repeats this for goroutines 2 to 5.
- Main calls
wg.Wait(). The counter is 0, so it returns immediately. - Main returns, the process exits, and any worker that never got a CPU slice never prints.
On a multi-core machine some workers will have started by then, which is why the output varies. That variation is what makes this nasty: a test box with a quiet scheduler may pass for months.
Quick detour: it is documented, not just folklore
The sync package documentation says calls with a positive delta that occur when the counter is zero must happen before a Wait. Put differently, Add racing with Wait on a zero counter is a misuse, not a quirk.
That is also why the race detector can flag it in some runs, and why you should not rely on it. It reports races that actually happen in that execution, and an early return is just the harmless-looking ordering most of the time.
The fix: Add before go
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker", i)
}()
}
wg.Wait()
Now by the time Wait runs, the counter is already 5, regardless of what the scheduler did. If you know the total up front, you can also call wg.Add(5) once before the loop.
Two small notes on that snippet. The defer wg.Done() is deliberate, so a panic or early return in the worker still decrements the counter. And the per-iteration i is safe because Go 1.22 gives each loop iteration its own variable.
Newer Go: let the library do it
Go 1.25 added WaitGroup.Go, which does the add, the goroutine start and the deferred done in one call:
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Go(func() {
fmt.Println("worker", i)
})
}
wg.Wait()
There is no way to get the ordering wrong because you never write it. If your module's go directive is older than 1.25, this will not compile, so check before sweeping it through a codebase.
Go 1.25 also added a go vet check that reports Add being called from inside the goroutine in simple cases like the first example. Worth running, but it will not catch every shape, such as an Add buried in a helper function.
Cases that look different but are the same bug
The loop is the textbook form. The same mistake hides in less obvious places:
- A worker that spawns further workers and calls
Addinside its own body, after the parent may already have been counted down to zero. - A handler that registers itself with a shared WaitGroup on entry, while shutdown code is already calling
Wait. - A
Start()method that launches a goroutine which does its ownAddon a group stored in the struct.
The second one is the interesting one, because there is no loop to fix. The rule is the same: whoever decides that work exists must record it before anyone can observe the count as zero. For a server that accepts work during shutdown, that usually means a mutex-guarded "closed" flag checked in the same critical section as the Add.
A test that would have caught it
Counting is easy to assert. Have each worker increment an atomic counter and check the total after Wait:
func TestAllWorkersRan(t *testing.T) {
var wg sync.WaitGroup
var n atomic.Int64
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n.Add(1)
}()
}
wg.Wait()
if got := n.Load(); got != 100 {
t.Fatalf("ran %d workers, want 100", got)
}
}
Move the Add inside the goroutine and run with go test -count=200. It will fail often enough to be obvious. The same test with the correct ordering never fails, which is the whole point of ordering guarantees.