skip to content

What do sync.WaitGroup's Add, Done and Wait do, and how do they fit together to join goroutines?

level: juniorimportance: must knowfreq 80%

answer

  1. a counter, not a queue
  2. raise it before you start the work
  3. defer the decrement on every return path
  4. the join unblocks at zero

basics

~10 s

A sync.WaitGroup is a counter of outstanding goroutines. Add(n) raises it before you start them, each goroutine calls Done to lower it by one, and Wait blocks the caller until the counter reaches zero.

solid answer

~50 s

A `sync.WaitGroup` is a counter with three operations. `wg.Add(1)` raises it, and it must run in the launching goroutine **before** the `go` statement, so the counter is already up when `Wait` is reached. Inside the new goroutine the first line is `defer wg.Done()`, which lowers the counter by one on every return path, including a panic. `wg.Wait()` blocks until the counter is zero, then returns; if it is already zero it returns immediately. In a migration runner that means: `Add(1)` per unit in the loop, `defer Done()` inside each unit's goroutine, one `Wait()` after the loop to join. The zero value is usable, so `var wg sync.WaitGroup` needs no constructor, but it must be shared by pointer — never copied into a helper. Since Go 1.25 `wg.Go(f)` starts `f` and does the Add/Done pairing for you.

code

go · 9 lines
go
var wg sync.WaitGroup
for _, m := range migrations {
	wg.Add(1)
	go func() { // m is per-iteration since Go 1.22
		defer wg.Done()
		apply(m)
	}()
}
wg.Wait() // returns once every apply has finished

go deeper

for a junior

Be ready to write the four lines from memory: declare the group, Add before the go statement, defer Done as the goroutine's first line, Wait after the loop. Say plainly that Wait blocks until the counter is zero.

for a middle

Explain why the Add must be outside the goroutine, why Done is deferred rather than called at the end, and that all methods are on the pointer so the group is shared and never copied.

for a senior

Show the production discipline: exactly one Add per launch, one deferred Done, and an awareness that Wait has no timeout and no cancellation, so a worker that blocks forever hangs the join forever.

for a principal

Frame WaitGroup as the minimum join contract in a codebase: it says only when work ended, not whether it succeeded, so decide early where error and result collection live rather than bolting them onto the group later.

## What it is `sync.WaitGroup` is Go's join primitive: it lets one goroutine block until a set of other goroutines has finished. It holds a single non-negative integer counter and nothing else — no goroutine identities, no results, no errors, no cancellation. Its zero value is ready to use, so `var wg sync.WaitGroup` is the whole setup. There is no `NewWaitGroup`. ## The three operations - **`wg.Add(delta int)`** adds `delta` to the counter. `delta` is usually `1`, but `Add(len(units))` once before a loop is equally valid. A negative delta is allowed — `Done` is exactly `Add(-1)` — and if the counter would drop below zero the runtime panics with `sync: negative WaitGroup counter`. - **`wg.Done()`** decrements the counter by one. It is what a finished goroutine calls to announce it is finished. - **`wg.Wait()`** blocks until the counter is zero and then returns. If the counter is already zero, it returns immediately rather than blocking. More than one goroutine may call `Wait` on the same group; all of them are released when the counter hits zero. ## The shape you write A migration runner that applies a set of changes concurrently and joins on their completion looks like this: ```go var wg sync.WaitGroup for _, m := range migrations { wg.Add(1) go func() { defer wg.Done() apply(m) }() } wg.Wait() ``` Two details in that snippet are the whole discipline. **`Add` happens in the launching goroutine, before `go`.** If you move `wg.Add(1)` inside the new goroutine, there is a window in which the counter is still zero: the loop can finish and `Wait` can observe zero and return before any worker has run, so the runner reports success having applied nothing. The Go scheduler gives you no ordering guarantee that saves you here — the `go` statement only starts a goroutine, it does not run it. Go 1.25's `go vet` gained a `waitgroup` analyzer that reports exactly this misplaced `Add`. **`Done` is deferred, and it is the first line of the goroutine.** Deferring it means every exit path decrements the counter: the normal return, an early `return` on an error, and a panic that unwinds the goroutine. A hand-placed `wg.Done()` at the bottom of the function is the classic source of a hang, because the one path that returns early skips it and `Wait` never unblocks. ## Sharing the group All `WaitGroup` methods are declared on `*WaitGroup`, and the group must be shared, not copied. A closure over a local `var wg sync.WaitGroup` shares it automatically. A helper function must take `*sync.WaitGroup`; taking `sync.WaitGroup` copies the counter and silently breaks the join. ## Reuse A group can be reused for a second batch, but only cleanly: a new `Add` that raises the counter from zero must not overlap an in-flight `Wait`. In practice, let `Wait` return, then start the next round of `Add` calls. Calling `Add` concurrently with a `Wait` that is waiting on a zero counter is a documented misuse and panics. ## Go 1.25: `wg.Go` ```go var wg sync.WaitGroup for _, m := range migrations { wg.Go(func() { apply(m) }) } wg.Wait() ``` `func (wg *WaitGroup) Go(f func())` increments the counter, starts `f` in a new goroutine, and decrements when `f` returns. It removes the two most common bugs — a misplaced `Add` and a missing `Done` — by making the pairing unskippable. It changes nothing about `Wait`. ## What a WaitGroup does not give you It does not bound concurrency: `Add(1)` in a loop over a million items starts a million goroutines. It does not collect return values or errors — you need a channel, a slice indexed per worker plus the join, or a mutex-guarded accumulator. It does not cancel anything: `Wait` waits, it never tells a worker to stop, so a worker blocked forever makes `Wait` block forever too. And it carries no timeout of its own; `Wait` has no deadline parameter. What it does give you is an ordering guarantee worth stating: each `Done` completes before the `Wait` it releases returns, so whatever a worker wrote before calling `Done` is safely visible to the joining goroutine after `Wait` returns.

  • Why must wg.Add run before the go statement rather than as the goroutine's first line?
    Because `go` only schedules the goroutine, it does not run it. If `Add` lives inside the new goroutine, the launching loop can finish and `Wait` can see a zero counter before any worker has executed a single line, so the join returns early and the program continues as if the work were done. `Add` in the launching goroutine closes that window. Go 1.25's `go vet` waitgroup check reports the misplaced form.
  • What does WaitGroup.Go, added in Go 1.25, change?
    `wg.Go(f)` increments the counter, runs `f` in a new goroutine, and decrements when `f` returns. You no longer write `Add`, the `go` statement, or `defer Done()` yourself, so the two commonest bugs — an `Add` in the wrong place and a return path that skips `Done` — become unwritable. `Wait` is unchanged, and the group still must not be copied.
  • Can the same WaitGroup be reused for a second batch after Wait returns?
    Yes. The zero value is usable and the counter simply returns to zero, so a second round of `Add`/`Done`/`Wait` is fine. The constraint is timing: a new `Add` that raises the counter from zero must not overlap a `Wait` that is still waiting. Let every `Wait` return before starting the next batch, otherwise the runtime reports a WaitGroup misuse and panics.

It is a tally at the door: you click the counter up once for each person you send in, each person clicks it down on the way out, and you stand at the door until the tally reads zero.

saying these in an interview costs you the question

  • Thinks Wait sleeps for a fixed period rather than blocking on a counter
  • Calls Add inside the goroutine it is counting
  • Writes wg.Done() at the bottom of the function instead of deferring it
  • Passes the WaitGroup by value and expects the caller's counter to change
  • Believes a WaitGroup collects the goroutines' return values or errors
  • Assumes Wait can be given a timeout argument