A sync.WaitGroup can be reused after a wait phase completes, but a new independent task set cannot begin while calls to Wait from the prior phase are still active. The boundary is the return of every earlier Wait, not merely the instant at which the internal task counter reaches zero.

This constraint matters when one WaitGroup instance is retained across batches, epochs, request waves, or repeated coordination cycles. Reuse is supported, but phases must not overlap at the zero-to-positive counter transition.

Zero is both a release point and a phase boundary

WaitGroup maintains a task counter. Positive Add calls increase it, Done is equivalent to Add(-1), and Wait blocks until the counter reaches zero.

When a final Done reduces the counter to zero, blocked waiters are released. Those waiters still need to return from Wait. Starting the next independent phase before those returns complete crosses the reuse boundary.

The documented ordering rule is specific: if a WaitGroup is reused for several independent sets of events, new positive Add calls for the next set must occur after all previous Wait calls have returned.

That rule is stronger than checking that prior workers have called Done. Worker completion causes the release, while waiter return completes the coordination phase.

A coordinator can make reuse explicit

A single coordinator naturally creates a non-overlapping boundary:

var wg sync.WaitGroup

for _, batch := range batches {
    for _, job := range batch {
        wg.Add(1)
        go func(job Job) {
            defer wg.Done()
            process(job)
        }(job)
    }

    wg.Wait()
}

Each iteration adds tasks while the counter is zero and before that iteration’s Wait. The next iteration does not execute its first Add(1) until the prior Wait has returned.

The same object carries several task sets, but its lifecycle is partitioned into distinct phases. Reuse does not require resetting a WaitGroup; there is no reset operation. The counter returning to zero and the waiters returning establish the usable boundary.

Concurrent phase ownership creates an ordering hazard

A more fragile design lets one goroutine wait for the current phase while another decides when to start the next phase:

var wg sync.WaitGroup

wg.Add(1)
go func() {
    defer wg.Done()
    runCurrentPhase()
}()

go func() {
    wg.Wait()
    markPhaseComplete()
}()

// A separate control path must not begin an independent
// zero-to-positive transition until the earlier Wait has returned.

A signal that the worker finished is not automatically equivalent to proof that every waiter has returned. If another control path performs the next positive Add based only on worker completion, phase ownership becomes ambiguous.

This is primarily a lifecycle ordering problem. The next producer needs a synchronization event tied to completion of the prior wait phase, rather than an observation that happens to correlate with the counter reaching zero.

Positive Add has different ordering rules at zero

The Add contract distinguishes two states. A positive Add that starts while the counter is greater than zero may occur while work is active. A positive Add that starts when the counter is zero must precede the corresponding Wait.

This distinction permits dynamic task creation inside an active phase. For example, an existing task can account for another task while the group remains non-empty, provided the program maintains the required task accounting.

var wg sync.WaitGroup
wg.Add(1)

go func() {
    defer wg.Done()

    if needsExtraWork() {
        wg.Add(1)
        go func() {
            defer wg.Done()
            runExtraWork()
        }()
    }
}()

wg.Wait()

Here the initial positive count is established before Wait. Further positive accounting can occur while the group is already non-empty. This is different from starting a fresh independent phase from zero while an earlier Wait remains in flight.

WaitGroup.Go follows the same empty-group boundary

Modern Go also provides WaitGroup.Go, which starts a task and accounts for its lifetime as one operation. When the group is empty, Go must happen before a Wait. When the group is non-empty, Go may happen at any time, including from a task already tracked by the group.

For repeated independent task sets, new Go calls must occur after all prior Wait calls have returned. The convenience method changes task-start bookkeeping, not the phase boundary for reuse.

WaitGroup.Go also carries a separate contract that the supplied function must not panic. That constraint is independent of reuse ordering.

Done establishes a memory-order edge to released waiters

Done has a synchronization guarantee beyond counter arithmetic. A Done call synchronizes before the return of a Wait call that it unblocks.

That edge makes Wait a useful completion barrier for state written by tracked tasks. Once the relevant Wait returns, code after it can rely on the synchronization relationship defined by the WaitGroup contract.

The guarantee does not turn a WaitGroup into a general phase-generation primitive. It carries no generation identifier and exposes no operation for reserving the next epoch while old waiters drain. Programs with independently advancing producers and consumers often need an additional mutex, channel, condition, or explicit generation state around phase transitions.

Reuse is valid only across non-overlapping generations

A reusable WaitGroup represents one active task count at a time. Its zero state is not a queue for future work and does not identify which generation a waiter belongs to.

Safe repeated use therefore depends on external lifecycle structure: the current generation reaches zero, its Wait calls return, and only then does the next independent generation create a new positive count. Keeping that ordering explicit preserves both task accounting and the synchronization boundary that WaitGroup provides.