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 thanAdd()โ the most common culprit, often from an accidental double-call. - Calling
Add()inside the goroutine rather than before launching it โ the scheduler can runDone()beforeAdd()even executes. - Reusing a WaitGroup while
Wait()is still active โ a newAdd()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 thegostatement โ never inside the goroutine. - Put
defer wg.Done()as the first line inside every goroutine, exactly once. - Function signatures take
*sync.WaitGroup(pointer), neversync.WaitGroup(value). - Don't call
wg.Add()afterwg.Wait()starts unblocking โ use a new WaitGroup instead. - Add
go test -race ./...to CI so these races surface automatically on every pull request.

