sync.WaitGroup, Mutex and errgroup: Coordinating Goroutines in Go
1. Overview
Running a goroutine in Go is easy—just add the go keyword. The hard part is knowing when goroutines are done, protecting shared data, and handling errors when a goroutine fails.
This post covers three tools for these three tasks: sync.WaitGroup, sync.Mutex, and errgroup.Group. After reading, you’ll know which tool to pick for each situation, how to write tests with -race, and common mistakes to avoid.
The examples use Go 1.22 and above. From this version onwards, each for loop creates a fresh variable for each iteration, so you don’t need to write id := id anymore.
2. The Example Problem
Suppose you have a list of order codes. Each order requires calling a slow service that takes around 100ms. If you call them sequentially for 10 orders, it takes about 1 second. If you call them concurrently, it takes only about 100ms.
Here’s a function that simulates the service call:
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
If you just write go fetchOrder(id) in the loop, the main function will return before the goroutines finish. The program exits and results are lost. The next section fixes this.
3. sync.WaitGroup: Waiting for a Group of Goroutines
3.1. Basic Usage
WaitGroup is a counter. You increment it with Add, decrement with Done, and Wait blocks until the counter reaches zero.
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
Here’s how the code above works:
wg.Add(1)is called before launching the goroutine, in the parent goroutine.defer wg.Done()ensures the counter always decrements, even if the goroutine exits early or panics.wg.Wait()blocksmainuntil all three goroutines callDone.
The output might look like this. The order of the fetched lines changes between runs:
fetched order-2
fetched order-1
fetched order-3
all done
3.2. WaitGroup.Go in Go 1.25
Starting with Go 1.25, WaitGroup has a new Go method. It combines Add, go, and Done into a single call:
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
This is shorter and avoids two common mistakes: forgetting to call Done, or calling Add inside the goroutine.
3.3. Collecting Results Without Locks
Often you need to keep results, not just print them. A safe way is to pre-allocate a slice and let each goroutine write to its own index:
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
No goroutine writes to the same element, so there’s no data race here. wg.Wait() also establishes a happens-before relationship. Therefore, after Wait, the parent goroutine can safely read all the data written by child goroutines.
Note: Never call
wg.Add(1)inside a goroutine. If you do,Wait()might run beforeAddand return while work is still pending.
4. sync.Mutex: Protecting Shared Data
4.1. What a Data Race Looks Like
Now suppose you need to accumulate revenue by customer, and multiple goroutines update the same map:
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
This program might crash with:
fatal error: concurrent map writes
Using a single int is even worse. The program doesn’t crash but silently produces wrong results, like 9730 instead of 10000. To catch this kind of bug, run with the race detector:
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Fixing with Mutex
sync.Mutex ensures that at any given time, only one goroutine runs the code between Lock and Unlock. The common pattern is to bundle the mutex with the data it protects in the same struct:
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
A few things to note:
- Every access to
total, including reads, must go through the lock. defer r.mu.Unlock()ensures the lock is always released, even if the function has multiplereturnpaths.- Methods use a pointer receiver (
*RevenueTracker). If you use a value receiver, each call copies the entire mutex, and that copy won’t protect anything.
When reads significantly outnumber writes, use sync.RWMutex. Read methods call RLock/RUnlock, write methods call Lock/Unlock. Multiple goroutines can read at once, but only one holds the lock during writes.
Note: Never copy a struct containing a
Mutexafter using it.go vetwill warn you with acopylocksmessage. Also, Go mutexes are not reentrant: if a goroutine callsLock()twice on the same mutex, it deadlocks.
4.3. When You Don’t Need Mutex
For simple counters, sync/atomic is cleaner and faster:
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
When data flows from one goroutine to another in a stream, channels are usually better. Mutex works best when you have shared state like a map or struct and multiple goroutines need to read or write it.
5. errgroup: WaitGroup with Error Handling
WaitGroup knows nothing about errors. If fetchOrder can return an error, you’d have to manually collect errors with a mutex or channel and cancel remaining goroutines yourself. The errgroup package does this for you.
5.1. Adding the Dependency
errgroup lives in the golang.org/x/sync module, not the standard library:
go get golang.org/x/sync
5.2. Collecting Errors and Canceling with Context
First, rewrite fetchOrder to accept a context.Context and return an error:
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("invalid order id %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
Here’s how the code above works:
errgroup.WithContext(ctx)creates a group and a child context. As soon as one goroutine returns an error, the child context is canceled.g.SetLimit(3)allows at most 3 goroutines to run concurrently. The nextg.Gocall blocks until a slot is free. This prevents overwhelming a downstream service when you have thousands of orders.g.Goaccepts afunc() errorand you don’t need to callAdd/Done.g.Wait()waits for all goroutines to finish and returns the first error. Later errors are discarded.fetchOrderlistens toctx.Done(), so remaining goroutines stop early instead of running the full 100ms.
Call the function with an invalid order ID:
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
invalid order id -5
If you need to try adding work without blocking when the limit is reached, use g.TryGo(f). It returns false instead of waiting.
Note: Context cancellation only works if the code inside the goroutine actively checks
ctx. IffetchOrderusedtime.Sleepwithout listening to the context, the goroutine would still run the full duration even after the group fails.
6. Writing Tests with -race
Go doesn’t have JUnit or AssertJ. You use the standard library’s testing package. The most important thing is to always run tests with the -race flag, because many concurrency bugs only show up with the race detector.
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
Run the tests:
go test -race ./...
ok example.com/shop 0.412s
Try removing the two Lock/Unlock lines from Add and run again. The test will fail with WARNING: DATA RACE or crash with concurrent map writes. This is a quick way to verify that the mutex is really necessary.
The second test also confirms that result order is preserved correctly: orders[i] matches ids[i], even though goroutines finish in any order.
7. Common Mistakes and Choosing the Right Tool
The most common mistakes:
- Calling
Addin a goroutine.Waitmight return too early. CallAddbeforego, or usewg.Go. - Passing
WaitGrouporMutexby value. The function gets a copy, and the original counter or lock doesn’t change. Pass a pointer*sync.WaitGroup, or use a closure. - Holding a lock during I/O. For example, making an HTTP call inside
Lock. Every other goroutine queues up, and concurrency is lost. Hold the lock for only the shortest critical section. - Using an empty
errgroup.Group(var g errgroup.Group) but expecting context cancellation. OnlyWithContextcreates a cancellable context. - Ignoring the return value of
g.Wait(). Errors silently disappear. - Launching unlimited goroutines for tens of thousands of outbound requests. Use
SetLimitor a worker pool.
Choosing the right tool:
| Situation | Tool |
|---|---|
| Wait for multiple error-free tasks | sync.WaitGroup |
| Multiple goroutines modify shared map or struct | sync.Mutex / sync.RWMutex |
| Simple counter or flag | sync/atomic |
| Tasks that may fail, need early cancellation, limit goroutine count | errgroup |
Note:
errgroupkeeps only the first error. If you need to know all errors—for example, to report which orders failed—have each goroutine write its error toerrs[i], then combine them witherrors.Join(errs...)after callingWait.
8. Conclusion
sync.WaitGroup answers “when is it done?”. sync.Mutex answers “who gets to modify the data right now?”. errgroup combines waiting with error handling, context cancellation, and limiting concurrent goroutines.
In real code, when calling multiple services concurrently, errgroup.WithContext with SetLimit is usually the sensible default. Whatever tool you choose, always run go test -race. It’s the cheapest way to catch data races before they hit production.