AlgoMaster Logo

errgroup

High Priority17 min readUpdated June 6, 2026

sync.WaitGroup waits for a fan-out of goroutines to finish, but it doesn't help with two problems that show up constantly in real code: collecting an error from a failed worker, and cancelling the siblings when one of them fails. errgroup.Group from golang.org/x/sync/errgroup is WaitGroup plus an error channel plus a cancellable context, bundled into one small type. It's the standard way Go programmers fan out work that can fail.

Installing the Package

errgroup lives in the golang.org/x/sync module, not the standard library. Before running any of the examples in this lesson, fetch the module once:

The package is maintained by the Go team and is widely used in production. It's "outside" the standard library only because the golang.org/x modules are versioned independently. Once you've added the dependency, importing it works like any other package.

If you forget the go get step, go run and go build will fail with no required module provides package golang.org/x/sync/errgroup.

What WaitGroup Leaves Unsolved

Consider a product page that fans out three calls in parallel: fetch the price, fetch the inventory, fetch the recommendations. Any one of the three can fail. The launcher needs to know which one failed, with which error, and stop wasting time on the other two if it's just going to return an error to the caller anyway.

With sync.WaitGroup alone, you have to build all of that yourself. Here's the WaitGroup-first version, and it's clunky.

It works, but look at what it took. A mutex to guard firstErr against concurrent writers. A helper closure to keep only the first error. Three near-identical goroutine bodies, each repeating the same if err != nil { setErr(err); return } shape. And the worst part: when inventory fails at 80ms, the price and recommendations goroutines keep running for another 40ms and 120ms respectively. The launcher waits for all of them before returning, even though it's already going to return an error.

This is the gap errgroup.Group fills. The same code with errgroup is shorter, safer, and (with WithContext) cancels the siblings as soon as one fails.

Same result, less ceremony. No mutex, no manual firstErr tracking, no Add / Done bookkeeping. g.Go(func() error) accepts a function that returns an error; whichever one returns the first non-nil error becomes the value g.Wait() returns. Subsequent errors from siblings are dropped silently, which is exactly what you want when "any failure means the whole operation fails."

What this version still doesn't do is cancel the siblings when one fails. The price and recommendations goroutines still run to completion. To fix that, you need errgroup.WithContext. That comes in the next section.

The errgroup.Group API

errgroup.Group is a small type with a small surface area. Like sync.WaitGroup, its zero value is usable. You don't construct it with a New function unless you want a context.

MethodWhat It Does
Go(f func() error)Calls Add(1) internally, spawns a goroutine that runs f, captures the returned error.
Wait() errorBlocks until every goroutine has returned. Returns the first non-nil error, or nil.
SetLimit(n int)Caps the number of active goroutines at n. Calls to Go block when the limit is hit.
TryGo(f) boolNon-blocking version of Go. Returns false if the limit is reached.

errgroup.WithContext(ctx) is the package-level constructor. It returns a *Group and a derived context.Context that's cancelled the moment any worker returns a non-nil error (or the parent context is cancelled). The cancellation isn't automatic if you don't use the derived context inside the workers, which is a common source of "why doesn't my errgroup cancel?" bugs.

Here's the package layout at a glance:

When worker 2 returns an error, errgroup cancels the derived context. Workers 1 and 3 are responsible for observing ctx.Done() and bailing out early. That's the contract. errgroup cancels the context for you; the worker code has to actually listen for it.

Cancelling Siblings with WithContext

The product-page example finishes faster when a failure cancels the in-flight siblings. Here's the rewrite using errgroup.WithContext. The fetch functions now take a context.Context and return early when it's cancelled.

The "after 80ms" is the key bit. As soon as fetchInventory returned its error, errgroup cancelled ctx. The fetchPrice and fetchRecommendations goroutines were both blocked in their select on <-time.After(...) and <-ctx.Done(). The cancellation flipped ctx.Done(), those selects picked the ctx.Done() branch, and both functions returned with ctx.Err(). Total wall time: ~80ms, not 250ms.

Two things to keep in mind. First, g.Wait() returns the error that triggered the cancellation, not the cancellation errors from the siblings. The siblings did return errors (context.Canceled), but those happened after the first error and errgroup drops them. Second, if your worker doesn't actually check ctx.Done(), the cancellation does nothing. A blocking system call that doesn't accept a context (time.Sleep, a no-deadline TCP read, etc.) will run to completion regardless.

Bounded Parallelism with SetLimit

The product-page example fans out to a known small number of calls. A different problem shows up when you have a large batch: enrich 1,000 orders by calling a pricing service for each one, say. Spawning 1,000 goroutines is technically fine for Go's scheduler, but it almost always hammers the downstream service, blows past its connection pool, and causes timeouts that spiral into worse failures. You want to cap concurrency.

(*Group).SetLimit(n) does exactly that. Once set, calls to Go block until the active goroutine count drops below n. The result is a worker-pool-like behavior with the same Go / Wait shape you already have.

Here's an enrich-orders example that processes 30 orders with a concurrency limit of 5.

A few details. SetLimit(5) caps the number of running goroutines at five. When the loop tries to launch the sixth order, g.Go blocks until one of the five in flight returns. The peak counter (tracked with sync/atomic so the workers don't race on it) confirms the cap is honored. When order 17 fails, the derived context is cancelled, and every other worker that hasn't yet called enrich(ctx, ...) exits via the <-ctx.Done() branch on its next chance, which makes the total wall time short instead of grinding through all 30 orders.

SetLimit(-1) (the default) means "no limit". SetLimit(0) is invalid and panics. The limit can only be set before any call to Go; changing it after the group is in use also panics.

Non-Blocking Submission with TryGo

SetLimit makes Go block when the limit is hit. Sometimes that's not what you want. If new work keeps arriving and you'd rather drop or queue it elsewhere when the pool is full, TryGo is the non-blocking alternative. It returns true if the function was scheduled and false if the group is at its limit.

The first two TryGo calls take the two open slots in the pool. Calls 3 through 6 hit the limit and return false, so they're never scheduled. The two that did get in run to completion, then Wait returns nil. Use TryGo when "I'd rather drop this request than wait for capacity" is the right behavior; use Go when you'd rather queue the call up.

TryGo doesn't help if you haven't called SetLimit. Without a limit, TryGo always returns true and behaves exactly like Go.

When the Parent Context Cancels

errgroup.WithContext(parent) is also cancelled when the parent is. That's how you wire errgroup into a request-scoped deadline or a server shutdown.

The 300ms timeout on the parent fires before any of the 2-second fetches complete. The parent's cancellation propagates into the derived ctx, every worker observes ctx.Done(), and they all return ctx.Err(). g.Wait() returns the first one it sees, which is context.DeadlineExceeded.

This is the most common shape in HTTP handlers: take the request's context, derive an errgroup from it, fan out, and let the handler's deadline cascade into every downstream call automatically.

The Whole Flow, in a Diagram

The interaction between one failing task, the derived context, and the surviving siblings is the heart of the package. Here's that flow as a sequence of states.

Worker B fails first. The package cancels the derived context. Workers A and C, both watching ctx.Done(), exit early. Wait returns the first error it saw, which is worker B's. The cancellation errors from A and C are discarded.

errgroup doesn't kill goroutines. It signals them. Each worker has to participate in cancellation by checking ctx.Done(), either directly or through context-aware libraries.

WaitGroup vs errgroup

The two types overlap in their core purpose (wait for N goroutines) but diverge sharply on what else they handle. Pick by what you actually need.

Featuresync.WaitGrouperrgroup.Group
Wait for N goroutinesYesYes
Error collectionNo, build it yourselfYes, first non-nil from Wait
Context cancellation on first errorNoYes, via WithContext
Bound concurrencyNo, build a worker poolYes, via SetLimit
Non-blocking submissionNoYes, via TryGo
Standard libraryYesNo, golang.org/x/sync
Worker signaturefunc(...) (any)func() error
Counter managementManual Add / DoneImplicit, inside Go
Reuse for multiple batchesYes, with careNo, one batch per group

Plain WaitGroup is the right call when the workers can't fail (or you genuinely don't care about their errors), no early cancellation is needed, and there's no concurrency cap. As soon as any of those constraints flips, errgroup is the cleaner choice. The one place WaitGroup still wins on its own is batch reuse: you can run multiple batches through a single WaitGroup, but an errgroup.Group is one-shot and not safe to use again after Wait returns.

Common Bugs

errgroup is small, but the surrounding code has a few recurring pitfalls.

Capturing the Loop Variable (Pre-Go 1.22)

If you're targeting any Go version before 1.22, the loop variable in a for loop is shared across iterations. Closures inside g.Go capture a reference, not a value, and by the time the goroutines run, the loop has finished and they all see the final value.

On Go 1.21 and earlier, all five goroutines see orderID == 5. The fix is the canonical i := i shadow, or pass the value as a parameter.

Go 1.22 changed the loop semantics: each iteration of a for loop now gets its own variable, so the original "broken" version works correctly. New code on Go 1.22+ doesn't need the shadow. Code that needs to compile on older toolchains, or code that you copy from older examples, still does. The shadow is harmless on newer versions, so it's a safe habit.

Forgetting to Use the Derived Context

errgroup.WithContext returns a derived ctx. If the workers don't use it, cancellation does nothing. This is the most common cause of "my errgroup doesn't cancel" reports.

The fix is to thread ctx into every worker and into every downstream call that accepts a context.

A worker that calls net/http, database/sql, os/exec with a context-aware API, or any well-behaved library function already participates in cancellation. The trap is custom code with bare time.Sleep or blocking channel reads that don't have a ctx.Done() branch.

Calling Wait Twice

A Group is single-use. After Wait returns, the group is "done" and calling Go again is a bug. The package doesn't panic on this, but the new goroutines won't be tracked by a subsequent Wait, so you'll get surprising results.

If you need two phases, create a fresh group for each phase. Groups are cheap to allocate.

Mutating Shared State Without Synchronization

errgroup doesn't change Go's memory model. Two goroutines writing to the same map, the same slice element, or the same struct field still race, and the race detector will flag it. The "each worker writes to its own slice index" pattern from the sync.WaitGroup lesson is just as valid here: it's the safest way to collect per-task results without a mutex.

A shared map[string]float64 or a single field on a shared struct would need a mutex or a channel for the results. errgroup saves you from the error-collection mutex; it doesn't save you from the result-aggregation mutex if you've designed the aggregation to need one.

When Not to Use errgroup

errgroup isn't the right answer for every concurrent problem.

  • Fire-and-forget background tasks. If you don't care when a goroutine finishes or whether it errors, plain go func() { ... }() is fine. errgroup is for jobs you wait on.
  • Long-running worker pools that process a stream. A persistent worker pool reads tasks from a channel and processes them indefinitely. errgroup is one-shot: Wait is meant to be called once after Go is done being called. For a long-lived pool, a channel-based design is a better fit.
  • Cases where you actually need every error. g.Wait() returns one error. If you need a []error with every failure (say, validating 100 records and reporting all the broken ones), errgroup will tell you only the first. Build your own collector or use a different abstraction.
  • Sequential pipelines. If the output of step 1 is the input to step 2, you don't have a fan-out problem. Use plain function calls or a channel-based pipeline.

The sweet spot for errgroup is "I have N independent jobs that can run in parallel, any failure should cancel the others, and I only need to know the first failure." That's a very common shape, which is why the package exists.

Quiz

errgroup Quiz

10 quizzes