The sync WaitGroup in Go and the Mistakes Everyone Makes
How sync.WaitGroup works in Go and the mistakes it invites: Add inside the goroutine, a missed Done, copying the group, reuse, and what go vet now catches.

The WaitGroup is the first synchronisation primitive most Go programmers learn, because the problem it solves comes up in the first concurrent program: how does main know the goroutines it started are finished? The type has three methods and a counter, which makes it look too simple to get wrong. It is wrong constantly. Each of its mistakes is easy to write, silent until the timing is unlucky, and obvious in hindsight.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide covers the model, then the mistakes one by one with a demonstration of each, then what go vet now catches for you and how Go 1.25's wg.Go retires the commonest ones. It belongs to the concurrency material and follows goroutines explained. Everything ran on Go 1.25.6.
The model
A WaitGroup holds a counter, initially zero.
wg.Add(n)addsnto the counter.wg.Done()subtracts one; it isAdd(-1).wg.Wait()blocks until the counter is zero.
The idiom is to add one for each goroutine before starting it, have each goroutine call Done when it finishes, and call Wait where you need them all finished:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var total atomic.Int64
for i := 1; i <= 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
total.Add(int64(i))
}()
}
wg.Wait()
fmt.Println("total:", total.Load())
}total: 15Five goroutines add their index to an atomic counter, and Wait returns only after all five have called Done, so the sum is always 15. A WaitGroup also gives you a memory-ordering guarantee: everything a goroutine did before Done is visible to the code after Wait returns (the memory model). That is why reading total after Wait is safe without anything further.
Mistake 1: calling Add inside the goroutine
The counter must be incremented before the goroutine starts, by the goroutine that is starting it. If Add happens inside the new goroutine, there is a window in which Wait runs while the counter is still zero:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
done := 0
var mu sync.Mutex
for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // too late: Wait may already have returned
defer wg.Done()
mu.Lock()
done++
mu.Unlock()
}()
}
wg.Wait() // the counter may still be zero here
mu.Lock()
fmt.Println("finished before Wait returned:", done)
mu.Unlock()
}go vet . 2>&1
true# example.test/lab
# [example.test/lab]
./main.go:15:10: WaitGroup.Add called from inside new goroutineWhether Wait returns early depends on the scheduler, which is the nature of the bug: on a loaded machine it returns early and the program prints the wrong count; on your laptop it usually looks fine. You do not have to rely on testing to find it. Since Go 1.25 go vet includes a waitgroup analyzer, and it reports the pattern at once, with the message WaitGroup.Add called from inside new goroutine, as the output above shows. The fix is to move Add(1) to before the go statement.
Mistake 2: forgetting Done on some path
Every Add needs a matching Done on every path out of the goroutine, including early returns and panics. Without it, Wait blocks forever:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
if i == 1 {
return // forgot Done on this path
}
defer wg.Done()
fmt.Println("worker", i)
}()
}
wg.Wait()
}worker 2
worker 0
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x102c1fff0?, 0x30?)
runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x14000092020)
sync/waitgroup.go:206 +0xa8
main.main()
example.test/lab/main.go:20 +0xa8
exit status 2One goroutine returns early without decrementing, so Wait never returns. Here nothing else is running, so the runtime reports fatal error: all goroutines are asleep - deadlock! and the stack shows sync.WaitGroup.Wait; in a server, where other goroutines are alive, the hang is silent (the deadlock page). The fix is mechanical: defer wg.Done() as the very first statement of the goroutine, before any condition. A deferred Done also runs when the goroutine panics and is recovered further up, which keeps the group consistent.
Mistake 3: copying the WaitGroup
A WaitGroup contains state that must not be duplicated. Passing one by value gives the function its own copy of the counter, so its Done never reaches the original:
package main
import (
"fmt"
"sync"
)
func worker(id int, wg sync.WaitGroup) { // wrong: takes a copy
defer wg.Done()
fmt.Println("worker", id)
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(1, wg)
wg.Wait()
}go vet . 2>&1
true# example.test/lab
# [example.test/lab]
./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy
./main.go:16:15: call of worker copies lock value: sync.WaitGroup contains sync.noCopygo vet reports worker passes lock by value: sync.WaitGroup contains sync.noCopy and call of worker copies lock value. These come from the copylocks analyzer, and the same applies to sync.Mutex, sync.Once and other types that embed noCopy. The program as written would hang, because the copy's Done lowers the copy's counter and the original's counter never reaches zero. Pass a pointer, *sync.WaitGroup, or capture the group in a closure. The cleanest design is often to keep the WaitGroup private to the function that starts the goroutines, so it never crosses a function boundary.
Mistake 4: a negative counter
Calling Done more often than Add panics:
package main
import (
"fmt"
"sync"
)
func main() {
defer func() { fmt.Println("recovered:", recover()) }()
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done() // one too many
}recovered: sync: negative WaitGroup counterThe panic message is sync: negative WaitGroup counter. It typically comes from a Done in both a deferred call and an explicit one at the end of a function, or from a goroutine started twice for one Add. The runtime panics rather than letting the counter go below zero, because the program's accounting is already wrong.
Mistake 5: reusing before Wait has returned
A WaitGroup can be reused for a second round of work, but only after every Wait of the first round has returned. Calling Add while another goroutine is still in Wait, with the counter at zero, is a race that can panic with sync: WaitGroup is reused before previous Wait has returned. The safe pattern is one WaitGroup per round, declared inside the loop or function that runs the round, so there is nothing to reuse:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func round(n int) int64 {
var wg sync.WaitGroup // fresh each round
var sum atomic.Int64
for i := 1; i <= n; i++ {
wg.Go(func() { sum.Add(int64(i)) })
}
wg.Wait()
return sum.Load()
}
func main() {
fmt.Println(round(3), round(4), round(5))
}6 10 15A group that lives for the whole program and serves as the "all background work finished" counter is the opposite case: its Add calls come from many places and its single Wait is at shutdown. There the rule is that no Add may run after Wait has started when the counter could be zero; stop accepting new work before you wait.
Mistake 6: using it where you need results or errors
A WaitGroup tells you that goroutines finished. It does not carry values or errors. People bolt on a shared slice and a mutex, which works and is clumsy. When the goroutines can fail and you want the first error, or want to cancel the rest, errgroup is the right tool: it wraps a WaitGroup, collects the first error and optionally cancels a context (errgroup). When each goroutine produces a value, writing to its own slot in a pre-sized slice needs no lock (returning errors from goroutines).
Waiting with a timeout
Wait has no timeout parameter and cannot be cancelled. When you need to give up after a while, for example at shutdown when a worker might be stuck, run the wait in its own goroutine and select against a timer:
package main
import (
"fmt"
"sync"
"time"
)
func waitTimeout(wg *sync.WaitGroup, d time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
func main() {
var fast, slow sync.WaitGroup
fast.Go(func() { time.Sleep(10 * time.Millisecond) })
slow.Go(func() { time.Sleep(300 * time.Millisecond) })
fmt.Println("fast group finished:", waitTimeout(&fast, 100*time.Millisecond))
fmt.Println("slow group finished:", waitTimeout(&slow, 100*time.Millisecond))
}fast group finished: true
slow group finished: falseThe helper reports whether the group finished in time. Note what it does not do: it does not stop the stuck goroutine, and the helper's own goroutine stays blocked in Wait until the group finishes, so a timed-out wait still leaks that helper until the workers end. This is acceptable at process shutdown, where the process is about to exit anyway, and a poor idea inside a loop. For real cancellation, the workers need to watch a context, which is why the combination of errgroup and a context is the better structure when stuck workers are possible (graceful shutdown).
A WaitGroup for shutdown
A common long-lived use is tracking background goroutines so the program can wait for them on exit. The shape is a small type that owns the group and starts goroutines through a method, so every Add is paired with a Done:
package main
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
type Tracker struct {
wg sync.WaitGroup
}
func (t *Tracker) Go(f func()) { t.wg.Go(f) }
func (t *Tracker) Wait() { t.wg.Wait() }
func main() {
ctx, cancel := context.WithCancel(context.Background())
var t Tracker
var stopped atomic.Int32
for range 3 {
t.Go(func() {
<-ctx.Done() // a background worker
stopped.Add(1)
})
}
time.Sleep(10 * time.Millisecond)
cancel() // begin shutdown
t.Wait() // wait for every worker to notice
fmt.Println("workers stopped:", stopped.Load())
}workers stopped: 3Shutdown cancels the context, the workers observe it and return, and Wait completes when the last one has. The ordering rule is the one from earlier: after Wait begins, no new Go calls, so stop whatever creates workers first. A tracker like this is the backbone of a clean exit (starting and stopping goroutines in order takes it further).
WaitGroup, channels or errgroup
The three overlap, and the choice is mostly about what else you need:
| You need | Use |
|---|---|
| To wait for N goroutines, nothing else | sync.WaitGroup (with wg.Go) |
| To wait and get the first error | errgroup.Group |
| To wait, get an error and cancel siblings | errgroup.WithContext |
| To limit concurrency while waiting | errgroup with SetLimit, or a semaphore |
| To collect results as they arrive | a channel, closed by a coordinator after Wait |
| To wait for exactly one event | a channel, not a WaitGroup |
A WaitGroup with a counter of one is a clumsy channel; use chan struct{} and close it. A WaitGroup is the right tool when the number of goroutines is dynamic and you only care that all of them finished.
Testing code that uses WaitGroup
The failures in this guide are timing-dependent, so tests need to provoke them. Run concurrent code with -race. Wrap the call under test in a timeout so a missing Done fails the test instead of freezing it: start the function in a goroutine, close a channel when it returns, and select on that channel and time.After. For code that must wait for background work, expose a way to wait (a Wait or Close method) and call it at the end of the test; a test that returns while goroutines still run is a source of flakes and of -race reports in a later test. And run go vet in CI: the copylocks and waitgroup analyzers are the cheapest defence in this whole guide.
Internals, briefly
A WaitGroup is a small struct holding an atomic 64-bit state that packs the counter and the number of waiters, plus a semaphore. Add is an atomic add; when the counter reaches zero and there are waiters, it releases them through the semaphore. Wait is an atomic read when the counter is already zero, which is why waiting on a finished group is nearly free. The type is cheap enough that creating one per call is not a concern, and the noCopy field exists only for go vet; it takes no space. The practical consequence is that you can use a WaitGroup freely in hot code and should worry about the work the goroutines do, not about the group.
The fix for most of this: wg.Go
Go 1.25 added WaitGroup.Go. It takes a function, does the Add(1), starts the goroutine and calls Done when the function returns, whatever the path:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var count atomic.Int32
for range 100 {
wg.Go(func() {
count.Add(1)
})
}
wg.Wait()
fmt.Println(count.Load())
}100With wg.Go there is no Add to misplace, no Done to forget on an early return, and the closure captures the group by reference so there is nothing to copy. It makes mistakes 1 through 3 structurally impossible. Mistake 4 (a negative counter) cannot happen either, since you no longer call Done yourself. The method is covered on its own in WaitGroup.Go. One limit: the function passed to Go takes no arguments and returns nothing, so you capture what you need, and since Go 1.22 each loop iteration has its own variable, so capturing the loop variable is safe.
Why the old triple of Add, go and Done existed
The mistakes above exist because for a decade the only way to use a WaitGroup was a three-part ritual spread across a loop body: wg.Add(1) outside, go func() inside, defer wg.Done() first in the closure. Each part had to be in the right place relative to the others, and nothing checked it. Code review caught some, and the race detector caught few of them because the failures are logic errors and not data races. Teams wrote tiny helper wrappers, a Go(f) method on their own type, which is exactly what the standard library adopted in Go 1.25. If your code base has such a helper, it can now be deleted in favour of wg.Go. If you work on a module whose go line is below 1.25, the method is not available, and the old ritual (or your own wrapper) is what you write; the go vet checks still help, since the analyzers ship with the toolchain.
A last note on judgment. A WaitGroup is a counter, and counters are easy to get subtly wrong in programs where goroutines start other goroutines. If a worker may spawn more workers after the parent has called Wait, the counter can hit zero too early. The remedy is to make the spawning worker call Add before it returns (so the counter never reaches zero while work remains), or to restructure the work as a queue with a known end (worker pools). When you cannot say, in one sentence, why the counter reaches zero exactly when the work is done, the design needs another pass.
A checklist
Addbefore thegostatement, in the caller. Or usewg.Go.defer wg.Done()first in the goroutine.- Never pass a
WaitGroupby value. Rungo vet. - One group per round of work.
- No
Addafter the finalWaithas begun. - If you need errors or cancellation, use
errgroup. - In tests, run with
-raceand a-timeoutso a hang fails instead of freezing CI.
The reference is the sync.WaitGroup documentation, which states the rule about Add ordering in its text, and the Go 1.25 release notes mention both WaitGroup.Go and the new vet analyzer.


