errgroup & golang.org/x/sync Extensions
Coordinated goroutines with first-error cancellation.
Summary
golang.org/x/sync/errgroup groups goroutines that share fate: one failure cancels the rest and surfaces a single error from Wait.
Related packages in x/sync add bounded parallelism (SetLimit), deduplication (singleflight), and counting semaphores (semaphore).
These extensions fill gaps in the stdlib sync package for service-style orchestration.
Recipe
Quick-reference recipe card - copy-paste ready.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(4)
g.Go(func() error { return stepA(ctx) })
g.Go(func() error { return stepB(ctx) })
if err := g.Wait(); err != nil {
return err
}When to reach for this:
- Parallel HTTP or gRPC fetches that should abort together on failure.
- Batch jobs with a concurrency ceiling (
SetLimit). - Cache stampede prevention with
singleflight.Group. - Long-lived services limiting background tasks with
semaphore.Weighted.
Working Example
// go.mod: module example.com/errgroupdemo
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
"golang.org/x/sync/singleflight"
)
func fetch(ctx context.Context, name string, delay time.Duration, fail bool) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
if fail {
return fmt.Errorf("%s failed", name)
}
fmt.Println("fetched", name)
return nil
}
}
func parallelFetch(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(2)
tasks := []struct {
name string
d time.Duration
fail bool
}{
{"users", 20 * time.Millisecond, false},
{"orders", 30 * time.Millisecond, false},
{"inventory", 10 * time.Millisecond, true},
}
for _, t := range tasks {
t := t
g.Go(func() error {
return fetch(ctx, t.name, t.d, t.fail)
})
}
return g.Wait()
}
func dedupeLoad(g *singleflight.Group, key string) (string, error) {
v, err, _ := g.Do(key, func() (any, error) {
time.Sleep(50 * time.Millisecond)
return "data-for-" + key, nil
})
if err != nil {
return "", err
}
return v.(string), nil
}
func main() {
ctx := context.Background()
if err := parallelFetch(ctx); err != nil {
fmt.Println("parallel:", err)
}
var sf singleflight.Group
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s, _ := dedupeLoad(&sf, "account:42")
fmt.Println(s)
}()
}
wg.Wait()
sem := semaphore.NewWeighted(1)
if err := sem.Acquire(ctx, 1); err == nil {
defer sem.Release(1)
fmt.Println("critical section")
}
}What this demonstrates:
WithContextties goroutine lifetime to shared cancel.SetLimit(2)prevents more than two fetches at once.singleflightruns one loader per key while waiters share the result.semaphore.Weightedblocks with context awareness unlike raw channel tricks.
Deep Dive
How It Works
errgroup.Groupembeds aWaitGroupplus an error slot protected by mutex.- First non-nil error from
Gotriggerscancelon the derived context fromWithContext. SetLimit(n)uses an internal channel semaphore;n < 1means unlimited.singleflight.Docoalesces concurrent calls with the same key until the leader fn returns.
x/sync Package Map
| Package | Type | Role |
|---|---|---|
| errgroup | Group | Parallel tasks, first error wins |
| semaphore | Weighted | Acquire/release capacity with ctx |
| singleflight | Group | Suppress duplicate in-flight work |
| syncmap | Map | Concurrent map (niche; often prefer plain map+mutex) |
Go Notes
// errgroup without cancel - use when failures should not stop siblings
var g errgroup.Group
g.Go(func() error { return maybeFail() })
_ = g.Wait()- Use plain
errgroup.Groupwhen partial success is acceptable and you merge errors yourself. - Always pass the derived ctx from
WithContextinto I/O, not the parent.
Gotchas
- Using parent ctx inside
Goafter sibling failed - Work continues after cancel. Fix: Use derivedctxonly. - Calling
GoafterWait- Undefined behavior; create a new Group per batch. Fix: One group per logical operation. - SetLimit without respecting ctx in long tasks - Slots stay occupied until task ends. Fix: Honor
ctx.Done()in fetch bodies. - singleflight forgetting shared error - Waiters receive the same error - usually desired. Fix: Do not use for unrelated keys on one Group.
- Panic inside
Go- Crashes process; errgroup does not recover. Fix: Keep panics out; use safe wrappers in production. - Replacing WaitGroup everywhere - Overkill when no shared cancel needed. Fix:
sync.WaitGroupfor simple join.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
sync.WaitGroup + err chan | Custom error merge logic | Standard first-error-cancel is enough |
parallel.For (Go 1.22+ iter) | CPU slices with sync errors | Mixed I/O with cancel |
| Worker pool channel | Long-lived queue | One-shot parallel batch |
Manual context.WithCancel | Fine-grained partial cancel | Boilerplate without benefit |
FAQs
What does errgroup add over WaitGroup?
Automatic error return and optional context cancel on first failure.
When should I use WithContext?
When any failure should stop sibling goroutines - typical for request handlers fanning out.
What does SetLimit(0) mean?
Unlimited parallelism - same as not calling SetLimit.
Can I collect all errors, not just the first?
Use a mutex slice, or run tasks sequentially, or third-party multi-error types.
errgroup intentionally returns one.
Is singleflight a cache?
No - it only dedupes concurrent loads.
Pair with an actual cache for stored results.
Does singleflight prevent thundering herd across processes?
Only within one process.
Use distributed locks or cache for cross-instance dedupe.
How do I test errgroup cancel?
Start a slow task and a failing fast task; assert slow returns context.Canceled.
semaphore vs buffered channel?
Semaphore supports weighted acquire, context cancel, and clearer API for dynamic limits.
Should gRPC streaming use errgroup?
Often yes for concurrent send/recv helpers tied to stream lifecycle.
Is x/sync stable?
It is an official Go extension module versioned separately from stdlib.
Pin in go.mod and verify at CI time.
Can nested errgroups share context?
Derive child WithContext from parent ctx so outer cancel still applies.
What happens if Wait is never called?
Goroutines still run but leaks possible if they block forever - always Wait.
Related
- Advanced Concurrency Basics - errgroup intro example
- Fan-In, Fan-Out & Pipeline Stages - errgroup in pipelines
- Worker Pools & Task Queues - SetLimit vs pools
- Rate Limiting & Backpressure - semaphore.Weighted
- Cancellation Propagation in HTTP Handlers - handler ctx
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).