Skip to content
gohacks.dev
Concurrency

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.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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

main.goGo
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)
}
Text
Wait returned: task 2 failed

Four 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:

main.goGo
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))
}
Text
error: disk full
siblings stopped early: 3 | waited about 20ms

One 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:

main.goGo
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)
}
Text
failed: input 2: strconv.Atoi: parsing "x": invalid syntax

The 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:

main.goGo
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)")
}
Text
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:

main.goGo
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")
}
Text
start-up check failed: cache: connection refused
decided in about 20ms not 5s

The 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.

Also inBasics
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.