Fix 'panic: sync: WaitGroup is reused before previous Wait has returned' in Go

intermediateπŸ”· Go2026-07-17| Go 1.18+ on Linux, macOS, Windows β€” any concurrent code using sync.WaitGroup in loops or nested goroutines

Error Message

panic: sync: WaitGroup is reused before previous Wait has returned
#go#sync#waitgroup#goroutine

The error

panic: sync: WaitGroup is reused before previous Wait has returned

Hit this in a Go service processing batch jobs in a loop. The WaitGroup lived outside the loop and got reused each round. At some point, wg.Add() fired while the previous wg.Wait() hadn't fully wound down. Go's runtime catches this immediately and panics β€” better than silently corrupting state, but still a pain to track down in production.

Why this happens

Go has one hard rule: never call wg.Add() with a positive delta while the counter is transitioning to zero. "Transitioning to zero" means Wait() is in the middle of unblocking its callers. Call Add() during that window and you get the panic.

Two patterns that reliably trigger it:

Pattern 1 β€” WaitGroup reused across loop iterations:

var wg sync.WaitGroup

for round := 0; round < 3; round++ {
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            time.Sleep(10 * time.Millisecond)
        }(i)
    }
    wg.Wait() // Wait returns on round 0...
    // Next iteration immediately calls wg.Add(1).
    // If Wait() hasn't fully returned yet internally, this races β†’ panic
}

Pattern 2 β€” Add() called inside a goroutine after Wait() may have started:

var wg sync.WaitGroup

wg.Add(1)
go func() {
    defer wg.Done()
    // Spawning more work here is unsafe β€” if this goroutine
    // is the last one, Done() drops counter to 0, Wait() unblocks,
    // and then the child Add() races with that unblocking.
    wg.Add(1) // ← can panic
    go func() {
        defer wg.Done()
    }()
}()
wg.Wait()

Fix 1: Create a new WaitGroup per batch

Declare var wg inside the loop. A fresh WaitGroup each round has zero leftover state β€” nothing to race against. This solves the problem in most loop-based patterns.

for round := 0; round < 3; round++ {
    var wg sync.WaitGroup // fresh each round

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            time.Sleep(10 * time.Millisecond)
        }(i)
    }
    wg.Wait()
}

Fix 2: Call Add() for all goroutines before any of them start

Know the total upfront? Set the counter once, then launch:

var wg sync.WaitGroup

tasks := fetchTasks()
wg.Add(len(tasks)) // set the counter before any goroutine runs

for _, task := range tasks {
    go func(t Task) {
        defer wg.Done()
        process(t)
    }(task)
}
wg.Wait()

The counter starts at len(tasks) and only decrements from there. Add() is never called while Wait() is active, so the race can't happen.

Fix 3: For nested goroutines, call Add() before the parent calls Done()

A goroutine that spawns children must register those children before it decrements. This keeps the counter positive the whole time:

var wg sync.WaitGroup

wg.Add(1)
go func() {
    // Register children BEFORE this goroutine calls Done()
    wg.Add(2)

    go func() {
        defer wg.Done()
        doWork()
    }()
    go func() {
        defer wg.Done()
        doWork()
    }()

    wg.Done() // parent finishes, but counter is still 2 (the children)
}()
wg.Wait()

The rule: the counter must never reach zero while you still plan to call Add(). Zero is the point of no return.

Fix 4: Use errgroup for complex fan-out

Anything beyond a simple batch gets messy fast. golang.org/x/sync/errgroup handles the Add/Done/Wait bookkeeping and collects errors from goroutines β€” two problems, one package:

import "golang.org/x/sync/errgroup"

func runBatch(tasks []Task) error {
    g := new(errgroup.Group)

    for _, task := range tasks {
        t := task
        g.Go(func() error {
            return process(t)
        })
    }

    return g.Wait()
}

Each g.Go() call is internally safe. No manual Add/Done needed, and g.Wait() returns the first non-nil error from any goroutine.

Verify the fix

Always run with the race detector. It catches WaitGroup misuse before it hits production and gives a much clearer stack trace than the panic message itself:

go run -race main.go

# for tests
go test -race ./...

Want to stress-test it? Hammer the function in a tight loop:

for i := 0; i < 500; i++ {
    go runYourBatchFunction()
}
time.Sleep(5 * time.Second)

No panic, no race detector output β€” you're done.

Quick tips

  • Always develop with -race. The production panic message is vague. The race detector gives you a full goroutine stack showing exactly which Add() fired too late.
  • Never pass WaitGroup by value. Always use *sync.WaitGroup. Copying an in-use WaitGroup is undefined behavior and triggers the same class of panic.
  • Declare WaitGroup close to where it's used. A package-level WaitGroup shared across functions is a reuse accident waiting to happen.
  • The counter must stay positive any time you plan to call Add() again. If it can hit zero and Wait() can return, spin up a fresh WaitGroup for the next batch.

Related Error Notes