sync.WaitGroup is how you wait for goroutines to finish before main (or another coordinator) moves on. Without a wait, the process can exit while workers are still running. This page walks through the counter model (Add, Done, Wait), loop patterns, WaitGroup.Go on newer toolchains, channels and errors, comparisons with mutexes and channels, and mistakes that cause panics or deadlocks. For background, see goroutines in Go, channels, mutexes, and context.
Go 1.24 on Linux.
WaitGroup.Goexamples require Go 1.25 or later; they are marked non-runnable here when your toolchain is older.
What is sync.WaitGroup in Go?
A WaitGroup is a small counter in the sync package. You increment it before starting work (Add), each worker decrements it when finished (Done), and the coordinator blocks until the count hits zero (Wait). It answers “have all these goroutines finished?”—not “what did they compute?” and not “is shared memory safe?”
Why main can exit before goroutines finish
If you start a goroutine and return from main immediately, the runtime may shut down the program before the goroutine runs.
package main
import "fmt"
func main() {
go func() {
fmt.Println("this line may never print")
}()
// main returns; the program can exit here first
}sync.WaitGroup (or a server loop, or receiving from a channel) is how you wait so shutdown happens after work completes.
Basic WaitGroup example
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker finished")
time.Sleep(20 * time.Millisecond)
}()
wg.Wait()
fmt.Println("main finished")
}After Run you should see worker finished then main finished. The usual ordering is: Add before go, defer Done() inside the worker, then Wait in the goroutine that should block.
How Add, Done, and Wait work
| Call | Effect |
|---|---|
wg.Add(n) |
increases the counter by n (often 1 per worker) |
wg.Done() |
decreases the counter by one (same as Add(-1)) |
wg.Wait() |
blocks until the counter is zero |
defer wg.Done() right after Add in the worker body is idiomatic so Done still runs on early return or panic (you may add recover in production if you must always unblock Wait).
Example: wait for multiple goroutines
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Printf("task %d\n", i)
time.Sleep(50 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("all done")
}After Run you should see three task lines (order can vary) then all done.
WaitGroup with loops
Call wg.Add(1) inside the loop before each go, or call wg.Add(n) once before the loop if you launch exactly n goroutines.
Always pass the loop variable into the closure—do not close over i alone:
// Good: go func(i int) { ... }(i)
// Bad: go func() { fmt.Println(i) }() // i changes; classic bugWaitGroup.Go (Go 1.25+)
Go 1.25 added (*WaitGroup).Go(f func()), which starts f in a new goroutine and manages the Add/Done bookkeeping for you (see WaitGroup.Go in the sync docs). On Go 1.24 and earlier, that method does not exist—keep using Add / defer Done / go.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Go(func() { fmt.Println("task A") })
wg.Go(func() { fmt.Println("task B") })
wg.Wait()
fmt.Println("done")
}The function passed to Go must not panic in a way you rely on silently; treat panics like any other worker (consider recover if you need guarantees). Prefer Add/Done when you support multiple Go versions or need explicit per-branch counting.
Passing a WaitGroup to functions
Pass a pointer (*sync.WaitGroup) if helpers call Add/Done/Wait. A WaitGroup must not be copied after first use—copying breaks the internal state and can mis-synchronize with Wait.
func worker(wg *sync.WaitGroup, id int) {
defer wg.Done()
// ...
}WaitGroup with errors
WaitGroup only waits; it does not return errors from workers. Typical patterns:
- send
(result, err)on a channel and read afterWait - use
golang.org/x/sync/errgroupfor a group that shares context cancellation and aggregates the first error
Below, each worker sends at most one error (or nil) on a buffered channel; main waits, closes the channel, then drains—so you never read before producers finish.
package main
import (
"errors"
"fmt"
"sync"
)
func main() {
const n = 3
var wg sync.WaitGroup
errCh := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if id == 1 {
errCh <- fmt.Errorf("worker %d: %w", id, errors.New("boom"))
return
}
errCh <- nil
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
fmt.Println("seen:", err)
}
}
}After Run you should see one line starting with seen: for the failing worker. For first-error-only semantics, use a select with a non-blocking send or a dedicated errgroup worker pool.
WaitGroup with channels
A common pattern: launch workers that send results on a channel, call Wait, then close the results channel so one reader drains with for range. That avoids closing while producers still write. For a fuller pipeline sketch, see fan-out / fan-in.
package main
import (
"fmt"
"sync"
)
func main() {
const workers = 3
ch := make(chan int, workers)
var wg sync.WaitGroup
for w := 1; w <= workers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- id * 10
}(w)
}
wg.Wait()
close(ch)
sum := 0
for v := range ch {
sum += v
}
fmt.Println("sum", sum)
}After Run you should see sum 60 (that is 10 + 20 + 30, order of sends does not matter for the sum).
WaitGroup vs channels
- WaitGroup: join a set of goroutines; no data path.
- Channels: move values or signals between goroutines; often paired with a WaitGroup to know when it is safe to close.
The previous section is the usual combination: WaitGroup for “all writers finished,” channel for “here are the values.”
WaitGroup vs mutex
- WaitGroup: completion only.
- Mutex: protects shared mutable state. If workers update shared maps or structs, add a mutex (or channel discipline); a WaitGroup does not prevent data races by itself.
You often use both: wait until everyone is done, and serialize updates to shared data while they run.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
total := 0
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
total++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("total", total)
}After Run you should see total 5.
Common WaitGroup mistakes
Calling Add inside the goroutine
Wrong:
package main
import "sync"
func main() {
var wg sync.WaitGroup
go func() {
wg.Add(1)
defer wg.Done()
}()
wg.Wait()
}Wait can return before the goroutine runs Add, so the counter can hit zero too soon or you can race the counter updates.
Correct:
package main
import "sync"
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
}()
wg.Wait()
}Forgetting Done or calling it too many times
Missing Done leaves Wait blocked forever. Extra Done drives the counter negative and panics.
Copying a WaitGroup
Do not pass WaitGroup by value to another function that mutates it; use a pointer.
Reusing a WaitGroup incorrectly
You may start another phase after Wait returns and the counter is zero; do not overlap new Add calls with an in-flight Wait unless you fully control ordering.
Waiting while a goroutine is stuck
If a worker blocks forever (channel receive with no sender, deadlock), Wait blocks forever too—fix the worker logic or use context cancellation.
Best practices
Call Add before go, use defer wg.Done() at the top of the worker, pass *sync.WaitGroup when sharing, and keep the lifecycle easy to read. Use context.Context to cancel long work; use errgroup or error channels when failures matter.
Golang WaitGroup cheat sheet
| Situation | Use |
|---|---|
| Wait for goroutines to finish | sync.WaitGroup |
| Protect shared mutable data | sync.Mutex or channels |
| Send results between goroutines | channel |
| Cancel work | context.Context |
| Collect errors from a group | error channel or errgroup |
| Go 1.25+ shorthand for spawn + track | wg.Go(func(){ ... }) |
| Portable / explicit pattern | Add(1) + go + defer Done() + Wait |
Summary
sync.WaitGroup is a counter: Add declares how many goroutines you are waiting for, Done marks one finished (usually defer’d in the worker), and Wait blocks the coordinator until the count reaches zero—so main does not exit before goroutines finish. Loops need correct argument capture; WaitGroup.Go (Go 1.25+) reduces boilerplate but is not a replacement for mutexes or channels when you share state or stream data. For errors, add channels or errgroup; for cancellation, pair workers with context.
References
- Package sync — WaitGroup
- WaitGroup.Go (Go 1.25+)
- golang.org/x/sync/errgroup
- Effective Go — Concurrency

