---
title: 'Wait Groups'
source: 'https://academia.sh/en/courses/go-concurrency/wait-groups'
course: Concurrency
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Runs the Add, Done, and Wait triple's counter contract with five workers and shows that Wait's termination class is waited-for-arrived"
    - 'Measures, by checking without blocking, that a counter that never reaches zero blocks Wait forever, and records the leak as a boolean'
    - 'Measures with recover, without printing the panic text, that an extra Done call driving the counter below zero results in a panic'
---

# Wait Groups

A counter waits for five workers to finish: Add raises the counter to five before they start, each worker guarantees its own Done with defer, and Wait returns once the counter reaches zero — the termination class is waited-for-arrived again. If the counter never reaches zero, Wait never returns, and this leak is measured with a boolean; when Done is called one time too many, the counter drops below zero and the wait group panics, the panic is caught with recover, and only panic=true is written. Limiting measurement: which of the five workers finishes first is never measured, only that all of them finish — the results are collected and sorted before being printed.

The previous lesson measured a single goroutine waiting on a single lock. But the side
protecting shared state is itself often not a single goroutine but a group: several
goroutines splitting a piece of work into shares and running it in parallel, and a main
goroutine waiting for all of them to finish. A lock does not set up this kind of waiting —
a lock only limits how many goroutines can touch a resource at the same time, it does not
count how many goroutines have finished. The tool this lesson measures, the one that does
this counting, is `sync.WaitGroup`, a wait group.

It is possible to imitate a wait group with a channel — each worker sends an empty value on
a channel once it finishes, and `main` reads exactly as many values from that channel as
there are workers. The previous topic's Worker Pools and Fan-Out and Fan-In lessons
genuinely did this: the result channel itself both carried the value and counted how many
results had arrived. A wait group separates these two jobs: a separate counter for counting
how many workers finished, and, for carrying a value (as in this lesson), a separate
channel or no channel at all. This separation needs less machinery when the workers produce
no value at all — when knowing that a job finished is enough on its own.

## The Counter Contract

A wait group is not a slice, a channel, or a list — it is a type that holds a single
integer counter inside it. Its contract rests on three calls: `Add(n)` raises the counter
by `n`, `Done()` lowers it by one, and `Wait()` blocks until the counter reaches zero.
Using these three calls in the right order is a rule: `Add` must always be called before
the goroutine it concerns starts, `Done` must run on every exit path of every goroutine —
which is why it is written with `defer`, just like `Unlock` in the previous lesson — and
`Wait` never returns before the counter reaches zero.

Calling `Add` before the goroutine starts is not a matter of style. If `Add` were instead
called inside the goroutine itself, on its first line, `main` could reach `group.Wait()`
before that goroutine ever ran its own `Add` at all — in that case, since the counter is
still zero, `Wait` could return immediately, without ever waiting for a job that had not
even started. This is a class of bug that is hard to notice by reading the source, because
which order things run in depends on goroutine scheduling, and this lesson does not
deliberately run such a setup — because the result could vary from run to run, it would
fall under this course's determinism constraint. The rule can instead be read straight from
the source: the `group.Add(1)` line in the example sits before the `go` keyword, on every
step of the loop.

```go
// main.go — counter contract, a leak in a counter that never zeroes, a counter dropping below zero
package main

import (
	"fmt"
	"runtime"
	"sort"
	"sync"
)

func main() {
	fmt.Println("-- counter contract: Add once, Done on every path, Wait returns at zero --")
	const workerCount = 5
	var group sync.WaitGroup
	completed := make(chan string, workerCount)
	weights := []int{3, 1, 4, 1, 5}
	for i := 0; i < workerCount; i++ {
		group.Add(1)
		go func(index, weight int) {
			defer group.Done()
			sum := 0
			for j := 0; j < weight*1000; j++ {
				sum += j
			}
			completed <- fmt.Sprintf("job-%d (weight=%d, sum=%d)", index, weight, sum)
		}(i, weights[i])
	}
	group.Wait()
	fmt.Println("Wait returned: stopped, waited-for-arrived")

	close(completed)
	var reports []string
	for r := range completed {
		reports = append(reports, r)
	}
	sort.Strings(reports)
	for _, r := range reports {
		fmt.Println(r)
	}

	fmt.Println()
	fmt.Println("-- a counter that never reaches zero: Add is called, Done never is --")
	baseline := runtime.NumGoroutine()
	var leakingGroup sync.WaitGroup
	leakingGroup.Add(1)
	done := make(chan struct{})
	go func() {
		leakingGroup.Wait()
		close(done)
	}()
	select {
	case <-done:
		fmt.Println("Wait returned: this was not expected")
	default:
		fmt.Println("did Wait return: no, the counter never reached zero")
	}
	fmt.Println("is there a goroutine left that never stopped:", runtime.NumGoroutine() > baseline)

	fmt.Println()
	fmt.Println("-- the counter dropping below zero: panic --")
	panicOccurred := runAndRecover(func() {
		var overGroup sync.WaitGroup
		overGroup.Add(1)
		overGroup.Done()
		overGroup.Done()
	})
	fmt.Println("panic:", panicOccurred)
}

// runAndRecover runs the given function and reports whether it panicked; the stack trace is not printed.
func runAndRecover(fn func()) (panicOccurred bool) {
	defer func() {
		if recover() != nil {
			panicOccurred = true
		}
	}()
	fn()
	return false
}
```

```
-- counter contract: Add once, Done on every path, Wait returns at zero --
Wait returned: stopped, waited-for-arrived
job-0 (weight=3, sum=4498500)
job-1 (weight=1, sum=499500)
job-2 (weight=4, sum=7998000)
job-3 (weight=1, sum=499500)
job-4 (weight=5, sum=12497500)

-- a counter that never reaches zero: Add is called, Done never is --
did Wait return: no, the counter never reached zero
is there a goroutine left that never stopped: true

-- the counter dropping below zero: panic --
panic: true
```

**SS4.** Five workers are started with five weights (3, 1, 4, 1, 5) — one repeats twice —
and each computes its own sum and writes it to a channel. `group.Wait()` returns only after
all five have called their own `Done` — this is one more instance of the termination class
treated since the course's first lesson: `main` itself waits like a goroutine, and stops
once what it is waiting for — the counter reaching zero — arrives. The wait group here
offers a different contract than the lock: while a lock protects a single resource, a wait
group counts how many jobs have finished. The shared definition's third claim supports this
directly: the five "waited-for-arrived" lines were each set up with a separate mechanism
(an unbuffered receive, a closed channel, a lock, a wait group, a `select`), and the
dependency did not change even though the mechanism did. The previous lesson used the lock,
this lesson uses the wait group — both are members of the same family, the family of
"stopping depending on another goroutine's step."

The weights themselves were also chosen to differ on purpose: the weight `1` repeats twice
(`job-1` and `job-3`), while the remaining three (`3`, `4`, `5`) all differ. This shows that
the wait group does not distinguish between workers in any way — the counter only tracks
how many `Done` calls happened, it does not know which goroutine did which job. `job-1` and
`job-3` produce the same sum (`499500`) with the same weight, and there is no difference at
all between these two workers as far as the wait group is concerned; the distinction is
carried only by the `job-` prefixes in the output, not by the wait group itself.

**SS5.** The five workers finish at five different times — the one with a higher weight
runs a longer loop — but this lesson never measures which one finishes first. The reports
written to the channel are read from `completed` into a slice, then sorted and printed
with `sort.Strings`; the order of arrival from the channel is never printed, because that
order depends on goroutine scheduling and can vary from run to run. The only thing a wait
group guarantees is the moment the counter reaches zero; which `Done` call arrives first is
not a guarantee. This is the lesson's limiting measurement: that all five workers finished
is measured, which one finished first cannot be — had it been measured, this lesson's
output would also vary from run to run, and verification would report a mismatch.

The `group` variable is a local variable of `main`, and every goroutine accesses it through
a closure, with no pointer at all — because Go's closures capture the surrounding variables
by their own address, `group.Add`, `group.Done`, and `group.Wait` always touch the same
counter. A wait group is a struct, and the pass-by-value rule measured in the Go
Fundamentals course applies here as well: if a copy of `group` were taken and passed to a
function by value, that copy would have its own counter, independent of `main`'s own
counter — the contract would break silently. The inspector's job (the one measured in the
Go Command Family lesson) is exactly to catch patterns like this; this lesson does not
deliberately set up and run such a copy, because, like the single line falling outside the
lock's scope in the previous lesson, it would be a class of bug that is hard to notice by
reading the source.

The lock and the wait group split two separate responsibilities here. The previous
lesson's `ProtectedCounter` took on preventing more than one goroutine from writing to the
same value at the same time; whoever protects that value, it never answers the question
"did everyone finish?" The `completed` channel and `group` in this lesson do the opposite:
they count which workers finished their job, but never get involved with any memory the
workers share among themselves — the five workers here already write to no shared variable
at all, each computing its own local `sum` and sending it on the channel. In a real program
these two tools are seen together: several goroutines protect a shared value with a lock
and each calls `Done` once its job is finished, and `main` waits for all of them with
`Wait`, only reading the protected value once that happens. The lock decides what gets
protected, the wait group decides when it is ready to be read; neither question stands in
for the other.

## A Counter That Never Reaches Zero: The Leak

**SS6.** The second setup leaves the contract half done on purpose: `leakingGroup.Add(1)`
is called, but the matching `Done` is never called in any goroutine. Because the counter
will never reach zero again, the goroutine calling `leakingGroup.Wait()` stays blocked
forever — this is the fourth termination class itself: never stopped. To check this
blocking without waiting for it, we wrap `Wait` inside a separate goroutine and a closing
channel; the `default` branch of the `select` shows, instantly and with certainty, that no
value will ever arrive from that channel — because the counter can mathematically never
reach zero. This is not a guess about timing: which branch the `select` picks does not
change on any run, because `Wait` returning depends on the counter's value, not on timing,
and that value never changes.

**SS7.** The `runtime.NumGoroutine()` call is compared against the baseline count and
produces only a `true`/`false`, not a separate count of how many goroutines are left. This
is another instance of the shared definition's second claim: not stopping is not turned
into any error or warning by the Go runtime, the rest of the program keeps running
normally — only that one goroutine never finishes. This silence belongs to the same family
as the channel leaks measured in the Goroutines lesson: neither the wait group nor a
channel produces an error on its own when the other side fails to honor its contract.

A wait group has no cancellation mechanism of its own — there is no way to stop a `Wait`
call from the outside; it waits until the counter reaches zero. The `ctx.Done()` pattern
measured in the Timeouts and Deadlines lesson fills this gap, but the wait group itself
offers no such exit; this is why, in a real program, a `Wait` call carrying the risk of an
infinite wait is usually wrapped, together with a timeout, through a separate goroutine and
channel — exactly the way it is done with the `done` channel in this lesson. This lesson
used that wrapping only to check for the leak; setting up a real timeout and cutting the
wait short was the subject of the Timeouts and Deadlines lesson, and it is not repeated
here.

## The Counter Dropping Below Zero: Panic

**SS8.** The third setup models the opposite mistake: `Add(1)` is called once, but
`Done()` is called twice. The wait group does not silently ignore this — the moment the
counter drops negative, it panics, because a negative counter breaks the library's own
internal consistency, and Go's standard library chooses to report this immediately rather
than suppress it. `runAndRecover`, continuing the pattern of previous lessons, catches the
panic with `recover` and writes only `panic=true`/`panic=false`; the panic text itself is
never printed anywhere, because that text carries a stack trace.

Not printing the panic text is not only a concern about the stack trace. The text the
standard library gives this panic can change depending on the version that compiled the
source — there is no guarantee it stays word-for-word the same, because this text is not
part of a contract the language specifies, it is a detail of the library's internal
implementation. `panic=true`/`panic=false` instead measures the one thing the language
does guarantee: whether a panic happens when the counter drops negative, or not. Measuring
a second send to a closed channel the same way in the Closing and Ranging lesson is a
recurring habit of this course: the panic itself is measured, not the panic's text.

These two faulty setups build an interesting contrast: the counter never reaching zero
stays silent, while the counter reaching zero one too many times is reported loudly. This
difference comes from the wait group's own design, not from a judgment about which error
matters more. The counter can drop below zero because the wait group checks the counter on
every `Done` call and catches a negative value immediately; the counter never reaching
zero, on the other hand, is not something the wait group can check — the library has no way
of knowing how many `Add` calls went unanswered, because "going unanswered" is a wait that
can last forever, not an error. A panic can be caught because it is an instant event; a
leak cannot be caught because it is not an event but an unending state — it can only be
checked from the outside.

These three setups test the wait group's contract from three directions: `Wait` stops when
the counter reaches zero correctly, it never stops when the counter never reaches zero, and
it panics when the counter reaches zero one too many times. All three violations of the
contract come from the same root — the number of `Add` calls has to match the number of
`Done` calls, and it is the runtime, not the compiler, that checks this match. In the
previous lesson the lock's contract was about what had to stay under the lock; here the
contract is about how many `Add` calls correspond to how many `Done` calls. Both are rules
the compiler never sees at all: someone reading a source can only tell whether the `Add`
and `Done` calls are equal in number by watching the source run, not by reading it.

## Summary

- `sync.WaitGroup` is a counter that works with the `Add`, `Done`, and `Wait` triple;
  `Wait` blocks until the counter reaches zero, and the termination class is
  waited-for-arrived.
- The order in which five workers finish is never measured, only that all of them
  finish; the results are collected from a channel, sorted, and printed.
- If the counter never reaches zero, `Wait` blocks forever; this was checked without
  blocking, through `select`'s `default` branch, and the leak was measured only as a
  boolean.
- An extra `Done` call that drives the counter below zero makes the wait group panic;
  the panic was caught with `recover`, and only `panic=true` was written.
- The lock's contract was about what needed protecting; the wait group's contract is
  about how many `Add` calls correspond to how many `Done` calls — both are rules the
  compiler does not check, visible only by running the source.

## Next Step

A wait group counts a number, but on its own it protects no value and limits no operation
to a single run. Across two lessons, the lock was the only tool for protecting shared
state; the next lesson shows that this monopoly is not accurate, and measures two separate
primitives for shared state: once, which guarantees that a body runs exactly once no matter
how many goroutines call it, and atomic operations, which protect a single variable without
using a lock. Both primitives come with their own narrow contract — one protects only a
one-time setup, the other only a single variable — and where this narrowness is enough, and
where it falls short, is the next lesson's own limiting measurement.
