errgroup: Structured Concurrency Without the Bookkeeping

How golang.org/x/sync/errgroup wires a WaitGroup, error propagation, and context cancellation into one small helper: g.Go to launch, g.Wait to collect the first error, an automatically cancelled context so siblings stop, and SetLimit to bound how many run at once. Compiled and run against Go 1.26.5.

By now you have launched a batch of goroutines with a WaitGroup, waited for them, and cleaned up after them. You have also felt the two things a WaitGroup will not do for you: it does not carry back the error one of those goroutines hit, and it does not tell the others to stop when one of them fails. You end up bolting on a shared error variable behind a mutex, a context you cancel by hand, and a defer wg.Done() in every launch. That bolting-on is boilerplate, and boilerplate is where concurrency bugs hide.

golang.org/x/sync/errgroup is the standard answer. It is a small helper, maintained by the Go team in the x/sync module, that bundles exactly those three concerns into one type: wait for a group of goroutines, propagate the first error any of them returns, and cancel a shared context the moment that happens so the rest can bow out. It is what “structured concurrency” looks like in idiomatic Go, and it is worth learning as the default tool for “run these N things and fail as a unit.”

It lives outside the standard library, so you add it once:

$ go get golang.org/x/sync/errgroup

The shape of it

An errgroup.Group has two methods you will use constantly. g.Go(func() error) launches a goroutine and remembers its error. g.Wait() blocks until every launched function has returned, then hands you the first non-nil error any of them produced (or nil if all succeeded). That alone is a nicer WaitGroup: no Add, no Done, and a return value instead of a discarded one.

The version you almost always want is errgroup.WithContext, which adds the third piece. It returns a group and a derived context, and it cancels that context automatically the first time any g.Go function returns an error. Your goroutines watch that context; when one fails, the others see <-ctx.Done() fire and can quit early instead of grinding on work whose result is already doomed.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

func main() {
	g, ctx := errgroup.WithContext(context.Background())

	// Task 1: fails after a short delay.
	g.Go(func() error {
		time.Sleep(50 * time.Millisecond)
		return errors.New("task 1: disk full")
	})

	// Task 2: a long job that watches ctx and stops when it is cancelled.
	g.Go(func() error {
		select {
		case <-time.After(5 * time.Second):
			fmt.Println("task 2: finished its long work")
			return nil
		case <-ctx.Done():
			fmt.Println("task 2: observed cancellation, stopping:", ctx.Err())
			return ctx.Err()
		}
	})

	err := g.Wait()
	fmt.Println("Wait returned:", err)
}
task 2: observed cancellation, stopping: context canceled
Wait returned: task 1: disk full

Read the output against the clock. Task 1 sleeps 50ms and returns an error. That return does two things: it records disk full as the group’s error, and it cancels ctx. Task 2 was prepared to work for five seconds, but its select is also watching ctx.Done(), so within a moment of task 1 failing it wakes up on the cancellation branch and returns. g.Wait() unblocks once both functions are done and gives you the first error that landed, which is task 1’s. The five-second timer never fired. That is the whole point: one failure, and the group unwinds as a unit in milliseconds instead of seconds.

Two details worth internalizing. The context is cancelled by the first error only; if task 2 had also failed, its error would be dropped, because Wait returns just one. And the cancellation is cooperative, exactly as it was in the context chapter: errgroup cancels ctx, but a goroutine that never checks ctx.Done() will run to completion regardless. errgroup gives the others a chance to stop early; it cannot force a goroutine that ignores the signal.

One thing errgroup deliberately does not do is collect your results. A g.Go function returns only an error, so the useful value each task computes has to land somewhere you control. The idiomatic answer is a pre-sized slice indexed by position: each goroutine writes to its own index, which is race-free without a mutex because no two goroutines touch the same element. Fetching a batch of URLs, for instance, you allocate results := make([]Page, len(urls)) up front and have the closure for index i write results[i]. Then g.Wait() tells you whether the batch as a whole succeeded, and the slice holds what came back, in order. Reach for a channel and a collector goroutine only when the results stream in and you cannot pre-size the destination.

Bounding concurrency with SetLimit

Firing one goroutine per item is fine for a handful of tasks. Fire one per row of a million-row file, or one per URL in a giant list, and you have a different problem: you will open ten thousand database connections, or hammer an API into rate-limiting you, or simply exhaust memory. You want the convenience of “launch them all” with a cap on how many actually run at once.

g.SetLimit(n) does exactly that. After you set it, g.Go will block until an in-flight slot frees up rather than launching immediately, so no more than n of the group’s functions run concurrently.

package main

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

	"golang.org/x/sync/errgroup"
)

func main() {
	var g errgroup.Group
	g.SetLimit(2)

	var inFlight, peak int64

	for i := 0; i < 6; i++ {
		id := i
		g.Go(func() error {
			n := atomic.AddInt64(&inFlight, 1)
			// Track the high-water mark of concurrent goroutines.
			for {
				p := atomic.LoadInt64(&peak)
				if n <= p || atomic.CompareAndSwapInt64(&peak, p, n) {
					break
				}
			}
			fmt.Printf("task %d running, in-flight=%d\n", id, n)
			time.Sleep(30 * time.Millisecond)
			atomic.AddInt64(&inFlight, -1)
			return nil
		})
	}

	_ = g.Wait()
	fmt.Println("peak concurrent goroutines:", atomic.LoadInt64(&peak))
}
task 1 running, in-flight=1
task 0 running, in-flight=2
task 2 running, in-flight=2
task 3 running, in-flight=2
task 4 running, in-flight=2
task 5 running, in-flight=2
peak concurrent goroutines: 2

Six tasks were queued, but the limit was two, so the reported in-flight count never climbs above two and the measured peak is exactly 2. Tasks 2 through 5 did not start until one of the first pair finished and released its slot. Notice this replaces the whole worker-pool ceremony from an earlier chapter, the channel of jobs and the fixed set of workers draining it, with a single line. When all you need is “run this work, at most N at a time, and tell me if anything failed,” SetLimit is that pattern in one method call. (The counting here uses sync/atomic from the sync-toolkit chapter precisely because these increments run on many goroutines at once; run this one under -race and it stays clean.)

Two sharp edges. SetLimit must be called before any g.Go, and calling it while goroutines are already running panics. And with a limit set, g.Go blocks the calling goroutine when the group is full, so do not call it from inside one of the group’s own functions expecting to spawn more freely: you can deadlock a saturated group against itself. There is a non-blocking TryGo for the cases where you would rather skip than wait.

Why not just a WaitGroup

It is fair to ask what you actually gained, since a WaitGroup already waits. Here is the same three-goroutine batch with a raw WaitGroup, and the thing it cannot do:

package main

import (
	"errors"
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		id := i
		wg.Add(1)
		go func() {
			defer wg.Done()
			if id == 1 {
				// This error has nowhere to go. WaitGroup has no channel for it.
				err := errors.New("task 1 failed")
				_ = err
			}
		}()
	}

	wg.Wait()
	fmt.Println("all done — but did anything fail? WaitGroup cannot tell you.")
}
all done — but did anything fail? WaitGroup cannot tell you.

A WaitGroup counts, and that is all it does. The go func signature it launches returns nothing, so an error inside has no path out; task 1’s failure quietly evaporates. And nothing here cancels anything, so if tasks 0 and 2 were long-running, they would keep going after task 1 died, doing work whose result you are about to throw away. To recover both properties by hand you would add a shared error guarded by a mutex, a context.WithCancel you invoke on the first failure, and the discipline to check ctx.Done() everywhere. That is precisely the code errgroup already wrote, tested, and got right. Reach for a WaitGroup when you genuinely just need to wait and none of the goroutines can fail; reach for errgroup the moment failure or early cancellation enters the picture, which in real programs is most of the time.

Final thoughts

errgroup is the structured-concurrency helper Go’s standard toolkit was missing: g.Go launches, g.Wait collects the first error, and errgroup.WithContext cancels a shared context on that first failure so siblings watching ctx.Done() can stop early. SetLimit caps how many run at once, collapsing a hand-built worker pool into one line. It propagates the two things a bare WaitGroup drops on the floor, errors and cancellation, and it does so with code that is already correct. Make it your default for running a fixed set of fallible tasks as a unit.

Next: testing concurrent code — how to write a test that exercises goroutines, run it under -race, and use testing/synctest to make time-based code deterministic.

Comments