errgroup for Concurrent Tasks in Go
Run goroutines that can fail with golang.org/x/sync/errgroup, get the first error back, cancel the rest with a context, and limit concurrency with SetLimit.
sync.WaitGroup waits for goroutines, but it knows nothing about errors. When each goroutine can fail, you end up adding a channel or a mutex just to report errors. The errgroup package does this for you: it waits for a group of goroutines and returns the first error any of them produced.
errgroup lives in golang.org/x/sync, a module maintained by the Go team outside the standard library. Add it to your module first:
go get golang.org/x/sync/errgroup
Basic errgroup
package main
import (
"errors"
"fmt"
"golang.org/x/sync/errgroup"
)
func fetch(name string) error {
if name == "orders" {
return errors.New("orders service unavailable")
}
fmt.Println("fetched", name)
return nil
}
func main() {
var g errgroup.Group
for _, name := range []string{"users", "orders", "products"} {
g.Go(func() error {
return fetch(name)
})
}
if err := g.Wait(); err != nil {
fmt.Println("error:", err)
}
}
The output contains fetched users, fetched products (in either order) and then:
error: orders service unavailable
g.Go starts a goroutine that returns an error. g.Wait waits for all of them and returns the first non-nil error. There is no Add or Done to get wrong.
Cancelling the Others on First Error
Often, once one task fails, the rest are pointless. errgroup.WithContext returns a group and a context.Context that is cancelled as soon as any task returns an error. Tasks that check the context can stop early:
package main
import (
"context"
"errors"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func task(ctx context.Context, name string, d time.Duration, fail bool) error {
select {
case <-time.After(d):
if fail {
return fmt.Errorf("%s: failed", name)
}
fmt.Println(name, "finished")
return nil
case <-ctx.Done():
fmt.Println(name, "cancelled")
return ctx.Err()
}
}
func main() {
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return task(ctx, "quick", 10*time.Millisecond, false) })
g.Go(func() error { return task(ctx, "broken", 20*time.Millisecond, true) })
g.Go(func() error { return task(ctx, "slow", time.Second, false) })
err := g.Wait()
fmt.Println("error:", err)
fmt.Println("is cancellation:", errors.Is(err, context.Canceled))
}
Output:
quick finished
slow cancelled
error: broken: failed
is cancellation: false
When broken fails, the context is cancelled, slow stops instead of waiting a full second, and Wait returns the original error, not the cancellation errors that came after it.
Tasks must check the context:
Cancelling the context does not stop a goroutine by force. A task that never looks at ctx.Done() or passes ctx to the functions it calls keeps running until it finishes on its own. Pass ctx into HTTP requests and database calls so cancellation actually reaches them.
Limiting How Many Run at Once
SetLimit(n) turns the group into a simple worker pool: g.Go waits until fewer than n tasks are running.
package main
import (
"fmt"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
var g errgroup.Group
g.SetLimit(2)
var running, peak atomic.Int32
for i := 0; i < 6; i++ {
g.Go(func() error {
n := running.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
time.Sleep(15 * time.Millisecond)
running.Add(-1)
return nil
})
}
g.Wait()
fmt.Println("peak concurrency:", peak.Load())
}
This prints peak concurrency: 2. Six tasks ran, never more than two at a time.
Wait returns only the first error:
If three tasks fail, Wait reports one of them. When you need every error, have each task store its error, for example in a slice slot, and combine them with errors.Join after Wait.
errgroup Compared to WaitGroup
Tasks cannot fail, just wait -> sync.WaitGroup
Tasks return errors, want the first -> errgroup.Group
Stop the rest when one fails -> errgroup.WithContext
Also cap how many run at once -> g.SetLimit(n)
Clean concurrent error handling:
If your concurrent code has no hand-written error channels and no mutex just for errors, and every task receives ctx, errgroup is doing its job.
Summary
errgroup.Group is a WaitGroup for tasks that return errors: g.Go starts them and g.Wait returns the first error. errgroup.WithContext cancels a shared context when any task fails so the others can stop early, provided they check ctx. SetLimit caps concurrency, and when you need all errors rather than the first, collect them yourself and join them.