sync.WaitGroup is a fundamental synchronization primitive in Go's concurrency toolkit that enables elegant coordination between multiple goroutines. This documentation provides complete technical coverage with verified examples, best practices, and expert guidance for developers at all levels.
What is WaitGroup in Golang?
WaitGroup functions as a counting semaphore that allows a goroutine to wait for a collection of other goroutines to complete their execution. It's particularly valuable in scenarios where you need to ensure all concurrent tasks finish before proceeding with subsequent operations.
Key Characteristics:
- Zero-value ready: No explicit initialization required
- Thread-safe operations: All methods use atomic operations internally
- Memory efficient: Compact 16-byte structure on 64-bit systems
- Panic protection: Prevents negative counter states
- Memory model compliance: Provides proper happens-before guarantees
Internal Architecture:
The WaitGroup uses a sophisticated internal structure optimized for performance:
type WaitGroup struct {
noCopy noCopy
state atomic.Uint64 // Counter and waiter state
sema uint32 // Semaphore for blocking Wait()
}
The state field encodes both the work counter (low 32 bits) and waiter count (high 32 bits) in a single atomic value, enabling lock-free operations.

API Reference
Core Methods
Add(delta int)
- Increments or decrements the internal counter by delta
- Must be called before the corresponding
Wait()
when counter is zero - Panics if counter becomes negative
- Thread-safe for concurrent access
Done()
- Equivalent to
Add(-1)
- Should always be called with
defer
to ensure execution - Synchronizes-before the return of any
Wait()
call it unblocks - Critical for preventing deadlocks
Wait()
- Blocks the calling goroutine until counter reaches zero
- Uses runtime semaphores for efficient blocking
- Returns when all tracked goroutines have called
Done()
- Provides memory synchronization guarantees
Go 1.25 Enhancement: Go()
Method
The Go(f func())
method introduced in Go 1.25 significantly improves ergonomics:
func (wg *WaitGroup) Go(f func()) {
wg.Add(1)
go func() {
defer wg.Done()
f()
}()
}
This method automatically handles Add(1)
and defer Done()
, eliminating common errors and reducing boilerplate code.
Usage Patterns and Examples
Traditional Pattern
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1) // Add BEFORE launching goroutine
go func(id int) {
defer wg.Done() // Always use defer
// Perform work
fmt.Printf("Worker %d completed\\n", id)
}(i)
}
wg.Wait() // Block until all complete
Go 1.25 Simplified Pattern
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Go(func() {
// Work here - no manual Add()/Done()
fmt.Printf("Worker %d completed\\n", i)
})
}
wg.Wait()
Error Handling with Context
func workerWithTimeout(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
select {
case <-time.After(workDuration):
// Normal completion
case <-ctx.Done():
// Respect cancellation
return
}
}
Best Practices
Always Use defer
with Done()
This ensures Done()
is called even if the goroutine panics or has multiple return paths:
func worker(wg *sync.WaitGroup) {
defer wg.Done() // Guaranteed execution
if err := riskyOperation(); err != nil {
return // Done() still called
}
// Additional work...
}
Avoids:
- Deadlocks from missing
Done()
on error paths. - Negative counter panics from mismatched
Add()
/Done()
.
Call Add()
Before Launching Goroutines
// CORRECT
wg.Add(1)
go worker(&wg)
// WRONG - Race condition possible
go func() {
wg.Add(1) // Might happen after Wait()
defer wg.Done()
// work...
}()
Avoids:
- Race conditions where
Wait()
returns before all goroutines are counted. - Intermittent deadlocks caused by late
Add()
.
Pass WaitGroup by Pointer
WaitGroup must not be copied after first use due to its internal state:
// CORRECT
func startWorkers(wg *sync.WaitGroup) {
wg.Add(1)
go worker(wg)
}
// WRONG - Copies the WaitGroup
func startWorkers(wg sync.WaitGroup) {
wg.Add(1) // Operating on copy
}
Avoids:
- Hidden races and stuck waits from manipulating a copied
WaitGroup
. - Undefined behavior due to diverging internal counters.
Integrate Proper Cancellation
Use context for graceful shutdown and timeout handling:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
select {
case <-ctx.Done():
return // Proper cleanup
default:
// Perform work with context checking
}
}()
}
Performance Considerations
WaitGroup vs Channels Performance
Research shows performance characteristics vary by concurrency level:
- Low concurrency (≤100 goroutines): WaitGroup/Mutex ~3x faster than channels
- Medium concurrency (100-1000): Performance roughly equivalent
- High concurrency (1000+): Channels can be 7% faster than WaitGroup

Memory Efficiency
- Zero allocation: WaitGroup operations don't allocate memory
- Atomic operations: Uses compare-and-swap instead of mutex locks
- Cache-friendly: Compact structure minimizes cache line contention
Integration with Error Handling
Using errgroup Package
For scenarios requiring error propagation:
import "golang.org/x/sync/errgroup"
func processWithErrors(items []Item) error {
g := new(errgroup.Group)
for _, item := range items {
item := item // Capture loop variable
g.Go(func() error {
return item.Process()
})
}
return g.Wait() // Returns first error
}
Custom Error Collection
func processWithErrorCollection(items []Item) []error {
var wg sync.WaitGroup
errorCh := make(chan error, len(items))
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
if err := item.Process(); err != nil {
select {
case errorCh <- err:
default: // Channel full, skip
}
}
}(item)
}
wg.Wait()
close(errorCh)
var errors []error
for err := range errorCh {
errors = append(errors, err)
}
return errors
}
Testing and Debugging
Race Detection
Always test concurrent code with the race detector:
go test -race ./...
go run -race main.go
Testing WaitGroup Synchronization
func TestWorkerCompletion(t *testing.T) {
var wg sync.WaitGroup
var completed int32
numWorkers := 100
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt32(&completed, 1)
}()
}
wg.Wait()
if atomic.LoadInt32(&completed) != int32(numWorkers) {
t.Errorf("Expected %d completed, got %d",
numWorkers, completed)
}
}
Memory Model and Synchronization
WaitGroup provides critical happens-before guarantees:
Done()
synchronizes-beforeWait()
return- All writes before
Done()
are visible afterWait()
returns - Program order within goroutines is preserved
- Atomic state updates prevent race conditions
These guarantees ensure that data written by worker goroutines is visible to the goroutine that calls Wait()
, providing proper memory synchronization without additional locks.
Version Compatibility and Future
Go 1.25 Improvements
- New
Go()
method for simplified usage - Enhanced
go vet
checks for common WaitGroup mistakes - Improved documentation and examples
- Backward compatibility maintained for existing code
Integration with Modern Go Features
WaitGroup works seamlessly with:
- Context cancellation for timeout and cancellation
- Generics for type-safe worker patterns
- Error groups for structured error handling
- Structured concurrency patterns
Conclusion
sync.WaitGroup remains an essential tool for Go developers. Its efficient design, synchronization guarantees, and new Go 1.25 enhancements make it a go-to choice for managing goroutines that don’t require data exchange.
Key Takeaways:
- Always use
defer wg.Done()
- Call
Add()
before goroutines - Use
Go()
for cleaner code in Go 1.25+ - Combine with context and error handling where needed
- Test with race detection
- Understand performance trade-offs at scale
>>> Follow and Contact Relia Software for more information!
- coding
- golang
- Web application Development
- web development