Errgroup in Go for Parallel Work That Can Fail Midway
Use errgroup for goroutines that can fail: the first error wins, WithContext cancels siblings, results go in slots, and panics are not caught.

A sync.WaitGroup tells you that goroutines finished. It cannot tell you that one of them failed, or stop the others when it did. Most real fan-out has those two requirements: start N tasks, find out if any failed, and, if one did, stop wasting work on the rest. errgroup in the golang.org/x/sync module is the small package that does exactly this, and it is the right default for a batch of independent tasks that can fail.
Tested with Go 1.25.6 and golang.org/x/sync v0.19.0, macOS on Apple Silicon (arm64)
This article covers the API, the semantics worth knowing exactly, and the patterns around results, limits and panics. It builds on returning errors from goroutines and the WaitGroup guide, and it leads to bounded parallelism with SetLimit. Everything ran on Go 1.25.6 with golang.org/x/sync v0.19.0.
The basics
package main
import (
"errors"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
var g errgroup.Group
for i := 1; i <= 4; i++ {
g.Go(func() error {
time.Sleep(time.Duration(i) * 10 * time.Millisecond)
if i == 2 {
return errors.New("task 2 failed")
}
if i == 3 {
return errors.New("task 3 failed")
}
return nil
})
}
err := g.Wait()
fmt.Println("Wait returned:", err)
}Wait returned: task 2 failedFour tasks run concurrently; two fail. Wait blocks until all four have returned, then gives back the first non-nil error by time of return: task 2 failed, since it finished before task 3. The second error is discarded. That behavior is by design and the first thing to remember: errgroup reports one error, not all of them. The zero value errgroup.Group is ready to use, with no context and no limit.
WithContext: stop the siblings
WithContext returns the group and a derived context. The context is cancelled the first time a task returns an error, or when Wait returns, whichever comes first:
package main
import (
"context"
"errors"
"fmt"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
g, ctx := errgroup.WithContext(context.Background())
var stoppedEarly atomic.Int32
g.Go(func() error {
time.Sleep(20 * time.Millisecond)
return errors.New("disk full")
})
for range 3 {
g.Go(func() error {
select {
case <-time.After(2 * time.Second): // a long job
return nil
case <-ctx.Done():
stoppedEarly.Add(1)
return ctx.Err()
}
})
}
start := time.Now()
err := g.Wait()
fmt.Println("error:", err)
fmt.Println("siblings stopped early:", stoppedEarly.Load(), "| waited about", time.Since(start).Round(20*time.Millisecond))
}error: disk full
siblings stopped early: 3 | waited about 20msOne task fails after 20 ms, and the three long ones, each of which would have taken two seconds, see the cancelled context and return at once. Wait returns the first error, disk full, and not the context canceled errors from the siblings, since those came later. Returning ctx.Err() from stopped tasks is the convention: it keeps them out of the way, because the first error wins.
Two details worth stating exactly. The context only helps tasks that watch it. A task that never looks at ctx runs to completion, and Wait waits for it. And the derived context is also cancelled when Wait returns, so it must not be used after the group is done, for example to run more work.
Results: one slot per task
Go takes a function that returns only an error, so results leave the goroutines another way. The simplest safe way is a pre-sized slice with one slot per task, because tasks writing to different elements do not conflict:
package main
import (
"fmt"
"strconv"
"golang.org/x/sync/errgroup"
)
func main() {
inputs := []string{"12", "7", "x", "30"}
nums := make([]int, len(inputs))
var g errgroup.Group
for i, s := range inputs {
g.Go(func() error {
n, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("input %d: %w", i, err)
}
nums[i] = n
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println(nums)
}failed: input 2: strconv.Atoi: parsing "x": invalid syntaxThe slice is written by index, which is race-free, and Wait guarantees all writes have finished before the read. On failure, the error names the input, and nums holds whatever the other tasks managed. Since Go 1.22 the loop variables i and s are per-iteration, so the closure captures the right ones. When you need every error and not just the first, give each task its own error slot and join them after Wait, or use errors.Join inside tasks that run several steps (collecting multiple errors).
Limits: SetLimit and TryGo
By default Go starts a goroutine immediately, however many are running. For a batch of thousands, bound the parallelism with SetLimit(n), called before the first Go. Go then blocks until a slot is free, which also gives you backpressure on the loop that submits work:
package main
import (
"fmt"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
var g errgroup.Group
g.SetLimit(3)
var running, peak atomic.Int32
for range 12 {
g.Go(func() error {
n := running.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(5 * time.Millisecond)
running.Add(-1)
return nil
})
}
g.Wait()
fmt.Println("peak concurrency:", peak.Load(), "(limit 3)")
}peak concurrency: 3 (limit 3)Twelve tasks, never more than three at once. TryGo is the non-blocking version: it starts the function only if a slot is free and reports whether it did, which suits load shedding (bounded parallelism covers both). The limit must not be changed while goroutines are active; doing so panics, per the package documentation.
A realistic example: parallel health checks
Putting the pieces together, a start-up check that probes several dependencies in parallel under one deadline, failing fast and naming the culprit, is a natural errgroup job:
package main
import (
"context"
"errors"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type check struct {
name string
latency time.Duration
err error
}
func (c check) run(ctx context.Context) error {
select {
case <-time.After(c.latency):
if c.err != nil {
return fmt.Errorf("%s: %w", c.name, c.err)
}
return nil
case <-ctx.Done():
return fmt.Errorf("%s: %w", c.name, ctx.Err())
}
}
func main() {
checks := []check{
{"database", 10 * time.Millisecond, nil},
{"cache", 20 * time.Millisecond, errors.New("connection refused")},
{"queue", 5 * time.Second, nil}, // slow: would hold start-up for 5s
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
for _, c := range checks {
g.Go(func() error { return c.run(ctx) })
}
start := time.Now()
err := g.Wait()
fmt.Println("start-up check failed:", err)
fmt.Println("decided in about", time.Since(start).Round(20*time.Millisecond), "not 5s")
}start-up check failed: cache: connection refused
decided in about 20ms not 5sThe database probe succeeds, the cache fails after 20 ms, and the queue probe, which would have taken five seconds, is cancelled the moment the cache fails. Wait returns the cache error with its name attached, and the whole decision takes about 20 ms. Without the group, you would write the channel, the cancel and the first-error bookkeeping yourself; with it, the function is a loop and a wait. The outer WithTimeout gives the entire check a budget, and the group's derived context inherits it, so a probe that hangs is cut off at two seconds at worst.
What errgroup does not do
It does not recover panics. A panic in a task ends the program, with the task's stack trace. The source carries a comment explaining why the authors decided not to propagate panics: doing so delays them arbitrarily, turns the stack into a mere value, and risks deadlocks that hide the panic. If tasks may panic and the program must survive, recover inside the task and return an error (recover).
It does not retry, order or deduplicate. It is a join point with error and cancellation semantics, and nothing more.
It does not wait for goroutines you start yourself. Only functions passed to Go are tracked, and a goroutine you spawn inside a task is yours to wait for.
Go after Wait has started is a misuse for a group whose counter may be zero, as with a WaitGroup: add all the work before waiting, or from tasks that are themselves still running.
When to use it
Errgroup fits a batch of independent tasks where the first failure should stop the rest: fetching several URLs, validating a set of files, running dependent services at start-up. It does not fit a stream with stages (a pipeline, where channels carry the items, although an errgroup can run the stage goroutines for lifecycle and errors, as in pipeline errors), tasks that must all report their errors (collect into slots), or a case where you need a result even from failed tasks. For plain "wait until finished" with no errors or cancellation, sync.WaitGroup with wg.Go is lighter (WaitGroup.Go).
Testing
Test the three behaviors: that the first error is returned, that siblings see the cancelled context (check a flag set when ctx.Done() fires), and that the limit holds (peak concurrency, as above). Use -race, and a -timeout, because a task that ignores the context turns a cancellation test into a hang. The package is documented at pkg.go.dev/golang.org/x/sync/errgroup, and the module's source is at go.googlesource.com/sync.


