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() blocks main until all three goroutines call Done.

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 before Add and 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 multiple return paths.
  • 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 Mutex after using it. go vet will warn you with a copylocks message. Also, Go mutexes are not reentrant: if a goroutine calls Lock() 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 next g.Go call blocks until a slot is free. This prevents overwhelming a downstream service when you have thousands of orders.
  • g.Go accepts a func() error and you don’t need to call Add/Done.
  • g.Wait() waits for all goroutines to finish and returns the first error. Later errors are discarded.
  • fetchOrder listens to ctx.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. If fetchOrder used time.Sleep without 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 Add in a goroutine. Wait might return too early. Call Add before go, or use wg.Go.
  • Passing WaitGroup or Mutex by 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. Only WithContext creates a cancellable context.
  • Ignoring the return value of g.Wait(). Errors silently disappear.
  • Launching unlimited goroutines for tens of thousands of outbound requests. Use SetLimit or 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: errgroup keeps only the first error. If you need to know all errors—for example, to report which orders failed—have each goroutine write its error to errs[i], then combine them with errors.Join(errs...) after calling Wait.

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.

golangconcurrencygoroutineerrgroupsync