🧗 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:
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.
Related patterns
Propagate cancellation, deadlines, and request-scoped values across API boundaries and goroutine trees with context.Context.
concurrencyWorker PoolBound concurrency by feeding jobs to a fixed number of long-lived worker goroutines.
concurrencyFan-out / Fan-inDistribute work across multiple goroutines (fan-out) and merge their results back into one stream (fan-in).
concurrencySemaphoreLimit how many goroutines may run a section of code (or hold a resource) at the same time.
Check your understanding
Score: 0 / 51. 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.