A goroutine started with go runs independently of the code that launched it. The launcher keeps going, the goroutine keeps going, and unless the launcher waits, the program might exit before any of its workers finish. sync.WaitGroup is the standard way to wait for a known number of goroutines to complete before moving on.
Consider a tiny program that spawns three goroutines, each pretending to fetch a product. The launcher needs to know when all three are done so it can print a summary.
A first attempt might look like this.
The summary prints, the program exits, and the three goroutines never get a chance to print. main finishing terminates the whole process, goroutines and all. There's no built-in "wait for everything to finish" behavior, because Go doesn't track which goroutines you care about.
A naive fix is to sleep for "long enough" before exiting.
This works on a good day, but it's wrong for two reasons. If the fetches take longer than expected, the program prints the summary while goroutines are still working. If they finish quickly, you've burned the rest of the second sleeping for no reason. time.Sleep is guessing. What you want is "wait until the workers say they're done", and that's what sync.WaitGroup gives you.
sync.WaitGroup holds an internal counter, and exposes three methods that all operate on that counter.
| Method | What It Does |
|---|---|
Add(delta int) | Increase the counter by delta. Call it before spawning the goroutines. |
Done() | Decrease the counter by 1. Each goroutine calls this when it finishes. |
Wait() | Block until the counter reaches 0. The launcher calls this. |
The pattern is "I'm about to start N pieces of work, please wait until all N have reported back". Add(N) registers the intent, each Done() checks off one item, and Wait() blocks until the checklist is empty.
main adds 3 to the counter, spawns three goroutines, then calls Wait. Each goroutine decrements the counter as it finishes. When the counter hits zero, Wait returns and main continues.
Here's the same product-fetch example with a WaitGroup added.
The order of the "Fetched product" lines varies between runs, but the "All fetches complete" line is always last. The launcher waits exactly as long as needed, not a millisecond more.
Three rules to take away from this:
Add(1) is called before go fetchProduct(...), in the launcher's goroutine.Done() is called via defer at the top of the worker, so it runs even if the worker panics or returns early.Wait() is called once, after all the goroutines have been spawned.defer wg.Done()The defer placement is deliberate. A worker that returns early or panics still has to decrement the counter, otherwise Wait blocks forever.
Order 2 returns early, but defer wg.Done() still runs on the way out. (The order of lines varies; what matters is that all three goroutines call Done and Wait returns.)
Without defer, a refactor that adds an early return higher up in the function is one of the easiest ways to introduce a hang in Go. Get into the habit of putting defer wg.Done() as the very first line of any goroutine that owns a WaitGroup entry.
sync.WaitGroup must be passed by pointer. Passing it by value copies the internal counter, and the worker decrements its own copy, not the one the launcher is waiting on.
The Go developers anticipated this mistake and added a noCopy marker to sync.WaitGroup. The compiler doesn't refuse to compile your program, but go vet will flag it.
Running go vet on this prints something like:
If you run it without vet, the program deadlocks: the launcher waits on a counter of 2, the workers each decrement their own copy down to 1, and nothing ever brings the launcher's counter to 0. Always pass *sync.WaitGroup.
Cost: go vet runs automatically with go test. Wire it into CI so this class of bug never reaches a real system.
A product page on an online store often pulls data from several backend services in parallel: one call for the description, one for the price, one for reviews. Doing them sequentially adds up the latencies. Doing them in parallel gives you the slowest single call, which is much better.
Each supplier fetch runs in its own goroutine. Each result is written into a pre-allocated slice at the goroutine's own index. Because every goroutine writes to a different slot, there's no shared mutation and no mutex needed.
(The order in which goroutines finish doesn't matter, the for _, r := range results loop walks the slice in the order it was filled.)
The anonymous goroutine takes idx, name, delay, and stock as parameters. That copies them into the goroutine's own variables, which avoids the classic "loop variable captured by reference" trap. (Go 1.22 fixed that trap for for loops by giving each iteration its own variable, but passing the values explicitly is still common and explicit.) The pre-allocated results slice means each goroutine writes to a different index, no synchronization needed. The total runtime is roughly max(150ms, 80ms, 220ms), which is 220ms, not the sum of 450ms you'd get sequentially.
Cost: Writing to different indices of the same slice is safe without locks, but writing to different keys of the same map is not. Map writes need a mutex even when keys don't collide.
To aggregate into a shared structure instead of distinct slots, use sync.Mutex. For this lesson, the "each goroutine writes to its own index" pattern is enough.
sync.WaitGroup is small, but it has a few sharp edges worth knowing.
DoneA missed Done call leaves the counter above zero forever. The launcher blocks on Wait, the runtime detects that all goroutines are asleep, and the program panics with fatal error: all goroutines are asleep - deadlock!.
The fix is defer wg.Done() as the first line of the worker. If the worker has multiple early-return paths or could panic, defer is the only reliable way.
Add Inside the GoroutineAdd must be called in the goroutine that's about to call Wait, before any worker has a chance to call Done. Calling it inside the worker is a race: the launcher might reach Wait first, see counter 0, and return immediately.
The output is unpredictable. Sometimes you'll see all three lines, sometimes none, sometimes a subset. go run -race catches the race. The fix is to move wg.Add(1) into main, right before go.
Add After Wait Has ReturnedA WaitGroup can be reused, but only after Wait has returned and the counter is back to zero. Calling Add after Wait has already unblocked, on the same WaitGroup, while goroutines from a previous batch might still be calling Done, panics with sync: WaitGroup is reused before previous Wait has returned.
The safest rule is: one batch, one Wait. If you need a second batch, either declare a fresh WaitGroup or make sure the first batch is fully drained before adding more work.
If Done runs more times than Add did, the counter goes negative and the runtime panics with sync: negative WaitGroup counter. This usually happens when the same function calls Done twice (once at the top via defer, once at the bottom by mistake), or when Add(n) was called with a smaller n than the number of goroutines actually spawned.
The discipline is one defer wg.Done() per goroutine, no exceptions.
Add(n) vs Add(1) Per Loop StyleYou can Add(n) once with the total, or Add(1) once per iteration. Both work, but the per-iteration form is more robust.
(Line order varies.)
The per-iteration form has one big advantage: if the loop adds a continue or an early skip later, the counter stays in sync with the goroutines that actually started. Add(len(products)) followed by a continue would leave a phantom entry that Done never matches, and Wait would block forever.
Both forms compile to similar code. Picking the per-iteration form is mostly a defense against future edits.
A single WaitGroup can be reused across multiple batches, as long as each Wait fully returns before the next Add is called.
Each batch waits for its own goroutines before the next batch starts. Calling Add for batch 2 only happens after Wait returned for batch 1, which means the counter is cleanly at zero again.
Go MethodGo 1.25 added a Go method on sync.WaitGroup that combines Add(1), the goroutine launch, and the implicit Done. The result is shorter and harder to misuse, because there's no way to forget the matching pair.
Requires Go 1.25+.
Here's the supplier-fetch example rewritten with WaitGroup.Go.
wg.Go(func) does three things internally: it calls Add(1), it spawns a goroutine, and it arranges for Done to run when the function returns (whether normally or via panic). The boilerplate disappears, and so do the bugs around forgetting Add or Done.
Side-by-side with the pre-1.25 form:
| Pre-1.25 | Go 1.25+ |
|---|---|
wg.Add(1) | (handled by Go) |
go func() { defer wg.Done(); work() }() | wg.Go(work) |
The pre-1.25 form still works in Go 1.25, and you'll see plenty of it in existing codebases. Both produce the same behavior. New code targeting Go 1.25 or later should prefer Go because it removes two ways to get the pattern wrong.
Wait is unchanged in both versions. You still call it once, in the goroutine that needs to block until everything finishes.
Cost: wg.Go adds no measurable overhead compared to Add + go + defer Done. The choice is about readability and safety, not performance.
10 quizzes