Fix 'panic: sync: negative WaitGroup counter' When Managing Goroutines in Go

intermediate๐Ÿ”ท Go2026-04-28| Go 1.13+, Linux / macOS / Windows, any program using sync.WaitGroup with goroutines

Error Message

panic: sync: negative WaitGroup counter
#go#goroutine#sync#waitgroup#panic

The Error

Your Go program dies at runtime with something like this:

goroutine 1 [running]:
sync: negative WaitGroup counter
goroutine 1 [running]:
sync.(*WaitGroup).Add(0xc000012090, 0xffffffffffffffff)
        /usr/local/go/src/sync/waitgroup.go:74 +0x12d
main.main()
        /home/user/project/main.go:18 +0x68
exit status 2

The internal counter inside sync.WaitGroup dropped below zero. Go treats that as a fatal programming error and panics immediately โ€” no recovery, no graceful shutdown.

Root Cause

A WaitGroup counter must stay at zero or above. Three mistakes account for almost every occurrence of this panic:

  • Calling Done() more times than Add() โ€” the most common culprit, often from an accidental double-call.
  • Calling Add() inside the goroutine rather than before launching it โ€” the scheduler can run Done() before Add() even executes.
  • Reusing a WaitGroup while Wait() is still active โ€” a new Add() racing against the reset is explicitly forbidden by the Go spec.

Fix 1: Too Many Done() Calls

This is the classic trigger. Each wg.Done() subtracts 1 from the counter. Call it twice for a single Add(1) and the counter hits -1 โ€” instant panic.

Broken code:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)

    go func() {
        defer wg.Done() // first Done โ€” OK
        fmt.Println("working")
        wg.Done() // second Done โ€” counter hits -1 โ†’ PANIC
    }()

    wg.Wait()
}

Fix: One goroutine, one defer wg.Done(). Put it at the top and never call Done() manually alongside it.

go func() {
    defer wg.Done() // only here
    fmt.Println("working")
}()

Fix 2: Add() Called Inside the Goroutine

Goroutines don't start instantly. Between the go keyword and the goroutine actually running, the scheduler might execute other code โ€” including wg.Wait(). Putting Add(1) inside the goroutine creates a race where Done() fires before the counter was ever incremented.

Broken code:

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        go func(n int) {
            wg.Add(1)        // WRONG: Add inside goroutine
            defer wg.Done()
            fmt.Println(n)
        }(i)
    }

    wg.Wait()
}

Fix: Move wg.Add(1) to just before the go statement, in the same goroutine that owns the loop.

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)            // Correct: Add before go keyword
        go func(n int) {
            defer wg.Done()
            fmt.Println(n)
        }(i)
    }

    wg.Wait()
}

Fix 3: Passing WaitGroup by Value

Pass a WaitGroup by value and the function gets its own private copy. Done() inside that function decrements the copy's counter โ€” the original never moves. Depending on timing, the copy goes negative or the original blocks forever waiting for a zero it never sees.

Broken code:

func worker(wg sync.WaitGroup) { // value copy โ€” WRONG
    defer wg.Done()
    fmt.Println("working")
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go worker(wg)
    wg.Wait() // blocks forever, or panics
}

Fix: Always pass a pointer. This is one of the few cases in Go where the pointer rule is non-negotiable.

func worker(wg *sync.WaitGroup) { // pointer โ€” correct
    defer wg.Done()
    fmt.Println("working")
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go worker(&wg)
    wg.Wait()
}

Fix 4: Reusing a WaitGroup Too Early

When the counter hits zero, Wait() begins unblocking. At that exact moment, calling Add() from another goroutine races against the internal reset. The Go documentation explicitly forbids this โ€” the result is undefined behavior that can manifest as the negative counter panic.

Broken pattern:

// goroutine A: wg.Wait() is unblocking (counter just hit 0)
// goroutine B: wg.Add(1) races with the reset โ€” undefined behavior

Fix: Use a fresh WaitGroup per batch. Declaring it inside the loop costs almost nothing and eliminates the race entirely.

for batch := 0; batch < 3; batch++ {
    var wg sync.WaitGroup     // fresh WaitGroup per batch
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(batch, n)
        }(i)
    }
    wg.Wait() // fully done before next iteration
}

Catching It Early: Race Detector

Go ships a built-in race detector. Run it during development โ€” it spots WaitGroup misuse and data races with precise goroutine-level attribution, not just a panic stack trace:

go run -race main.go
go test -race ./...

When a race is detected you get the exact file, line, and goroutine involved. Fix it there, not in production.

Verification

Three commands to confirm the fix held:

# Normal run
go run main.go

# With race detector
go run -race main.go

# Full test suite
go test -race ./...

No panic, no output from -race. That's the green light.

Prevention Checklist

  • Call wg.Add(n) before the go statement โ€” never inside the goroutine.
  • Put defer wg.Done() as the first line inside every goroutine, exactly once.
  • Function signatures take *sync.WaitGroup (pointer), never sync.WaitGroup (value).
  • Don't call wg.Add() after wg.Wait() starts unblocking โ€” use a new WaitGroup instead.
  • Add go test -race ./... to CI so these races surface automatically on every pull request.

Related Error Notes