Skip to content
gohacks.dev
Concurrency

The sync WaitGroup in Go and the Mistakes Everyone Makes

How sync.WaitGroup works in Go and the mistakes it invites: Add inside the goroutine, a missed Done, copying the group, reuse, and what go vet now catches.

12 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

The WaitGroup is the first synchronisation primitive most Go programmers learn, because the problem it solves comes up in the first concurrent program: how does main know the goroutines it started are finished? The type has three methods and a counter, which makes it look too simple to get wrong. It is wrong constantly. Each of its mistakes is easy to write, silent until the timing is unlucky, and obvious in hindsight.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This guide covers the model, then the mistakes one by one with a demonstration of each, then what go vet now catches for you and how Go 1.25's wg.Go retires the commonest ones. It belongs to the concurrency material and follows goroutines explained. Everything ran on Go 1.25.6.

The model

A WaitGroup holds a counter, initially zero.

  • wg.Add(n) adds n to the counter.
  • wg.Done() subtracts one; it is Add(-1).
  • wg.Wait() blocks until the counter is zero.

The idiom is to add one for each goroutine before starting it, have each goroutine call Done when it finishes, and call Wait where you need them all finished:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var wg sync.WaitGroup
	var total atomic.Int64

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			total.Add(int64(i))
		}()
	}

	wg.Wait()
	fmt.Println("total:", total.Load())
}
Text
total: 15

Five goroutines add their index to an atomic counter, and Wait returns only after all five have called Done, so the sum is always 15. A WaitGroup also gives you a memory-ordering guarantee: everything a goroutine did before Done is visible to the code after Wait returns (the memory model). That is why reading total after Wait is safe without anything further.

Mistake 1: calling Add inside the goroutine

The counter must be incremented before the goroutine starts, by the goroutine that is starting it. If Add happens inside the new goroutine, there is a window in which Wait runs while the counter is still zero:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	done := 0
	var mu sync.Mutex

	for i := 0; i < 3; i++ {
		go func() {
			wg.Add(1) // too late: Wait may already have returned
			defer wg.Done()
			mu.Lock()
			done++
			mu.Unlock()
		}()
	}

	wg.Wait() // the counter may still be zero here
	mu.Lock()
	fmt.Println("finished before Wait returned:", done)
	mu.Unlock()
}
Shell
go vet . 2>&1
true
Text
# example.test/lab
# [example.test/lab]
./main.go:15:10: WaitGroup.Add called from inside new goroutine

Whether Wait returns early depends on the scheduler, which is the nature of the bug: on a loaded machine it returns early and the program prints the wrong count; on your laptop it usually looks fine. You do not have to rely on testing to find it. Since Go 1.25 go vet includes a waitgroup analyzer, and it reports the pattern at once, with the message WaitGroup.Add called from inside new goroutine, as the output above shows. The fix is to move Add(1) to before the go statement.

Mistake 2: forgetting Done on some path

Every Add needs a matching Done on every path out of the goroutine, including early returns and panics. Without it, Wait blocks forever:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			if i == 1 {
				return // forgot Done on this path
			}
			defer wg.Done()
			fmt.Println("worker", i)
		}()
	}
	wg.Wait()
}
Text
worker 2
worker 0
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x102c1fff0?, 0x30?)
	runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x14000092020)
	sync/waitgroup.go:206 +0xa8
main.main()
	example.test/lab/main.go:20 +0xa8
exit status 2

One goroutine returns early without decrementing, so Wait never returns. Here nothing else is running, so the runtime reports fatal error: all goroutines are asleep - deadlock! and the stack shows sync.WaitGroup.Wait; in a server, where other goroutines are alive, the hang is silent (the deadlock page). The fix is mechanical: defer wg.Done() as the very first statement of the goroutine, before any condition. A deferred Done also runs when the goroutine panics and is recovered further up, which keeps the group consistent.

Mistake 3: copying the WaitGroup

A WaitGroup contains state that must not be duplicated. Passing one by value gives the function its own copy of the counter, so its Done never reaches the original:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg sync.WaitGroup) { // wrong: takes a copy
	defer wg.Done()
	fmt.Println("worker", id)
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go worker(1, wg)
	wg.Wait()
}
Shell
go vet . 2>&1
true
Text
# example.test/lab
# [example.test/lab]
./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy
./main.go:16:15: call of worker copies lock value: sync.WaitGroup contains sync.noCopy

go vet reports worker passes lock by value: sync.WaitGroup contains sync.noCopy and call of worker copies lock value. These come from the copylocks analyzer, and the same applies to sync.Mutex, sync.Once and other types that embed noCopy. The program as written would hang, because the copy's Done lowers the copy's counter and the original's counter never reaches zero. Pass a pointer, *sync.WaitGroup, or capture the group in a closure. The cleanest design is often to keep the WaitGroup private to the function that starts the goroutines, so it never crosses a function boundary.

Mistake 4: a negative counter

Calling Done more often than Add panics:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	defer func() { fmt.Println("recovered:", recover()) }()
	var wg sync.WaitGroup
	wg.Add(1)
	wg.Done()
	wg.Done() // one too many
}
Text
recovered: sync: negative WaitGroup counter

The panic message is sync: negative WaitGroup counter. It typically comes from a Done in both a deferred call and an explicit one at the end of a function, or from a goroutine started twice for one Add. The runtime panics rather than letting the counter go below zero, because the program's accounting is already wrong.

Mistake 5: reusing before Wait has returned

A WaitGroup can be reused for a second round of work, but only after every Wait of the first round has returned. Calling Add while another goroutine is still in Wait, with the counter at zero, is a race that can panic with sync: WaitGroup is reused before previous Wait has returned. The safe pattern is one WaitGroup per round, declared inside the loop or function that runs the round, so there is nothing to reuse:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func round(n int) int64 {
	var wg sync.WaitGroup // fresh each round
	var sum atomic.Int64
	for i := 1; i <= n; i++ {
		wg.Go(func() { sum.Add(int64(i)) })
	}
	wg.Wait()
	return sum.Load()
}

func main() {
	fmt.Println(round(3), round(4), round(5))
}
Text
6 10 15

A group that lives for the whole program and serves as the "all background work finished" counter is the opposite case: its Add calls come from many places and its single Wait is at shutdown. There the rule is that no Add may run after Wait has started when the counter could be zero; stop accepting new work before you wait.

Mistake 6: using it where you need results or errors

A WaitGroup tells you that goroutines finished. It does not carry values or errors. People bolt on a shared slice and a mutex, which works and is clumsy. When the goroutines can fail and you want the first error, or want to cancel the rest, errgroup is the right tool: it wraps a WaitGroup, collects the first error and optionally cancels a context (errgroup). When each goroutine produces a value, writing to its own slot in a pre-sized slice needs no lock (returning errors from goroutines).

Waiting with a timeout

Wait has no timeout parameter and cannot be cancelled. When you need to give up after a while, for example at shutdown when a worker might be stuck, run the wait in its own goroutine and select against a timer:

main.goGo
package main

import (
	"fmt"
	"sync"
	"time"
)

func waitTimeout(wg *sync.WaitGroup, d time.Duration) bool {
	done := make(chan struct{})
	go func() {
		wg.Wait()
		close(done)
	}()
	select {
	case <-done:
		return true
	case <-time.After(d):
		return false
	}
}

func main() {
	var fast, slow sync.WaitGroup
	fast.Go(func() { time.Sleep(10 * time.Millisecond) })
	slow.Go(func() { time.Sleep(300 * time.Millisecond) })

	fmt.Println("fast group finished:", waitTimeout(&fast, 100*time.Millisecond))
	fmt.Println("slow group finished:", waitTimeout(&slow, 100*time.Millisecond))
}
Text
fast group finished: true
slow group finished: false

The helper reports whether the group finished in time. Note what it does not do: it does not stop the stuck goroutine, and the helper's own goroutine stays blocked in Wait until the group finishes, so a timed-out wait still leaks that helper until the workers end. This is acceptable at process shutdown, where the process is about to exit anyway, and a poor idea inside a loop. For real cancellation, the workers need to watch a context, which is why the combination of errgroup and a context is the better structure when stuck workers are possible (graceful shutdown).

A WaitGroup for shutdown

A common long-lived use is tracking background goroutines so the program can wait for them on exit. The shape is a small type that owns the group and starts goroutines through a method, so every Add is paired with a Done:

main.goGo
package main

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

type Tracker struct {
	wg sync.WaitGroup
}

func (t *Tracker) Go(f func()) { t.wg.Go(f) }
func (t *Tracker) Wait()       { t.wg.Wait() }

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	var t Tracker
	var stopped atomic.Int32

	for range 3 {
		t.Go(func() {
			<-ctx.Done() // a background worker
			stopped.Add(1)
		})
	}

	time.Sleep(10 * time.Millisecond)
	cancel() // begin shutdown
	t.Wait() // wait for every worker to notice
	fmt.Println("workers stopped:", stopped.Load())
}
Text
workers stopped: 3

Shutdown cancels the context, the workers observe it and return, and Wait completes when the last one has. The ordering rule is the one from earlier: after Wait begins, no new Go calls, so stop whatever creates workers first. A tracker like this is the backbone of a clean exit (starting and stopping goroutines in order takes it further).

WaitGroup, channels or errgroup

The three overlap, and the choice is mostly about what else you need:

You needUse
To wait for N goroutines, nothing elsesync.WaitGroup (with wg.Go)
To wait and get the first errorerrgroup.Group
To wait, get an error and cancel siblingserrgroup.WithContext
To limit concurrency while waitingerrgroup with SetLimit, or a semaphore
To collect results as they arrivea channel, closed by a coordinator after Wait
To wait for exactly one eventa channel, not a WaitGroup

A WaitGroup with a counter of one is a clumsy channel; use chan struct{} and close it. A WaitGroup is the right tool when the number of goroutines is dynamic and you only care that all of them finished.

Testing code that uses WaitGroup

The failures in this guide are timing-dependent, so tests need to provoke them. Run concurrent code with -race. Wrap the call under test in a timeout so a missing Done fails the test instead of freezing it: start the function in a goroutine, close a channel when it returns, and select on that channel and time.After. For code that must wait for background work, expose a way to wait (a Wait or Close method) and call it at the end of the test; a test that returns while goroutines still run is a source of flakes and of -race reports in a later test. And run go vet in CI: the copylocks and waitgroup analyzers are the cheapest defence in this whole guide.

Internals, briefly

A WaitGroup is a small struct holding an atomic 64-bit state that packs the counter and the number of waiters, plus a semaphore. Add is an atomic add; when the counter reaches zero and there are waiters, it releases them through the semaphore. Wait is an atomic read when the counter is already zero, which is why waiting on a finished group is nearly free. The type is cheap enough that creating one per call is not a concern, and the noCopy field exists only for go vet; it takes no space. The practical consequence is that you can use a WaitGroup freely in hot code and should worry about the work the goroutines do, not about the group.

The fix for most of this: wg.Go

Go 1.25 added WaitGroup.Go. It takes a function, does the Add(1), starts the goroutine and calls Done when the function returns, whatever the path:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var wg sync.WaitGroup
	var count atomic.Int32

	for range 100 {
		wg.Go(func() {
			count.Add(1)
		})
	}
	wg.Wait()
	fmt.Println(count.Load())
}
Text
100

With wg.Go there is no Add to misplace, no Done to forget on an early return, and the closure captures the group by reference so there is nothing to copy. It makes mistakes 1 through 3 structurally impossible. Mistake 4 (a negative counter) cannot happen either, since you no longer call Done yourself. The method is covered on its own in WaitGroup.Go. One limit: the function passed to Go takes no arguments and returns nothing, so you capture what you need, and since Go 1.22 each loop iteration has its own variable, so capturing the loop variable is safe.

Why the old triple of Add, go and Done existed

The mistakes above exist because for a decade the only way to use a WaitGroup was a three-part ritual spread across a loop body: wg.Add(1) outside, go func() inside, defer wg.Done() first in the closure. Each part had to be in the right place relative to the others, and nothing checked it. Code review caught some, and the race detector caught few of them because the failures are logic errors and not data races. Teams wrote tiny helper wrappers, a Go(f) method on their own type, which is exactly what the standard library adopted in Go 1.25. If your code base has such a helper, it can now be deleted in favour of wg.Go. If you work on a module whose go line is below 1.25, the method is not available, and the old ritual (or your own wrapper) is what you write; the go vet checks still help, since the analyzers ship with the toolchain.

A last note on judgment. A WaitGroup is a counter, and counters are easy to get subtly wrong in programs where goroutines start other goroutines. If a worker may spawn more workers after the parent has called Wait, the counter can hit zero too early. The remedy is to make the spawning worker call Add before it returns (so the counter never reaches zero while work remains), or to restructure the work as a queue with a known end (worker pools). When you cannot say, in one sentence, why the counter reaches zero exactly when the work is done, the design needs another pass.

A checklist

  1. Add before the go statement, in the caller. Or use wg.Go.
  2. defer wg.Done() first in the goroutine.
  3. Never pass a WaitGroup by value. Run go vet.
  4. One group per round of work.
  5. No Add after the final Wait has begun.
  6. If you need errors or cancellation, use errgroup.
  7. In tests, run with -race and a -timeout so a hang fails instead of freezing CI.

The reference is the sync.WaitGroup documentation, which states the rule about Add ordering in its text, and the Go 1.25 release notes mention both WaitGroup.Go and the new vet analyzer.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.