{} The Go Reference

Concurrency pattern · Intermediate

errgroup

Run a group of goroutines, wait for them all, capture the first error, and cancel the rest automatically.

Concurrency Intermediate ⏱ 2 min read Complete

🧗 Analogy

A climbing team is roped together on parallel pitches. The moment one climber falls (returns an error), the lead calls off the attempt and signals everyone to stop and come back — and reports what went wrong. That’s errgroup: wait for all, but abort on the first failure.

The problem

You run several things concurrently and any of them might fail. A sync.WaitGroup waits for them, but it doesn’t collect errors or stop the others when one breaks — you end up bolting on a shared error variable, a mutex, and a context. golang.org/x/sync/errgroup packages exactly that: wait + first-error + cancel-the-rest.

Structure

graph TD
G["errgroup.WithContext"] --> A["g.Go task A"]
G --> B["g.Go task B (fails)"]
G --> C["g.Go task C"]
B -. "first error cancels ctx" .-> A
B -. cancels ctx .-> C
G --> W["g.Wait → returns B's error"]

Idiomatic Go

g.Go launches each task; a failure cancels the shared ctx, and g.Wait returns the first error. Edit and Run:

errgroup.go — editable & runnable
package main

import (
"context"
"fmt"
"time"

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

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

jobs := []string{"alpha", "beta", "bad", "gamma"}
for _, job := range jobs {
	job := job // capture per iteration
	g.Go(func() error {
		if job == "bad" {
			return fmt.Errorf("job %q failed", job)
		}
		select {
		case <-time.After(50 * time.Millisecond):
			fmt.Println("done:", job)
			return nil
		case <-ctx.Done(): // a sibling failed → stop early
			fmt.Println("cancelled:", job)
			return ctx.Err()
		}
	})
}

if err := g.Wait(); err != nil {
	fmt.Println("group failed with:", err)
}
}

🐹 The everyday concurrency workhorse

errgroup is the answer to “run these N things at once, stop if one fails.” Add g.SetLimit(n) and it becomes a bounded worker pool with error handling baked in — combining Worker Pool, Semaphore, and Context cancellation in a few lines. It lives in golang.org/x/sync, the semi-official extended standard library.

Pitfalls

⚠️ You get the FIRST error, not all of them

g.Wait returns only the first non-nil error; the rest are discarded (their goroutines are cancelled). If you need to see every failure, collect errors yourself (e.g. into a slice guarded by a mutex, or via errors.Join). Also: tasks must actually watch ctx.Done() — errgroup can signal cancellation, but a goroutine ignoring the context will run to completion regardless.

When to use it — and when not

✅ Reach for it when

  • You fan work out concurrently and any task can fail — you want the first error and to stop the others.
  • You'd otherwise hand-roll a WaitGroup plus error-collecting plus context cancellation.
  • You want a built-in concurrency limit via SetLimit.

⛔ Think twice when

  • Tasks never fail — a plain sync.WaitGroup is enough.
  • You need *every* error, not just the first — collect them yourself.

Check your understanding

Score: 0 / 5

1. What does errgroup add over sync.WaitGroup?

errgroup is a WaitGroup plus a shared error and (with WithContext) a context that cancels the rest when one goroutine returns an error.

2. What does errgroup.WithContext give you?

The derived ctx is cancelled on the first error (or when Wait returns), so the other goroutines selecting on ctx.Done() can stop early.

3. How do you bound concurrency with errgroup?

SetLimit(n) makes g.Go block until a slot is free, turning the group into a bounded worker pool with error handling.

4. errgroup.Wait returns only the FIRST error. How do you collect them all?

errgroup stores the first non-nil error and cancels the rest — great for fail-fast. When you need every error (e.g. validating many inputs), have each task append to a mutex-protected slice (or send on a channel) and combine with errors.Join, instead of returning them to the group.

5. What's the classic errgroup bug to watch for?

If the func passed to g.Go ignores or logs an error instead of returning it, the group never sees it. And pre-1.22, `for _, x := range xs { g.Go(func() error { use(x) }) }` captured a shared x — pass it in or rebind. (Go 1.22+ fixed loop-var capture.)

Comments

Sign in with GitHub to join the discussion.