How to Use errgroup for Structured Concurrency in Go

Use errgroup.WithContext to run parallel goroutines that cancel automatically on the first error.

The foreman who blows the whistle

You are building a service that aggregates data from three microservices. You fire off three goroutines to fetch the data in parallel. Two succeed instantly. The third hangs for thirty seconds because a database is down. Your user waits thirty seconds. Meanwhile, the first two goroutines finish and sit around, or worse, keep doing work they don't need to do. You need a way to say: "If any of these tasks fail, cancel the rest immediately and report the error."

Go gives you goroutines and channels, but coordinating cancellation and error collection across a group of tasks quickly becomes boilerplate. You end up writing a context, a channel for errors, a sync.WaitGroup, and a select loop to handle the first failure. That pattern works, but it repeats across every project. The errgroup package from golang.org/x/sync packages this pattern into a single, reliable primitive. It acts like a foreman: it spawns the workers, watches for the first problem, blows the whistle to cancel everyone else, and hands you the report.

Structured concurrency in plain words

Structured concurrency means every goroutine has a clear lifecycle tied to a parent scope. When the parent scope ends, all child goroutines must end. This prevents goroutine leaks and makes reasoning about concurrent code easier. errgroup enforces this structure by grouping goroutines and providing a single entry point to wait for them all.

The core idea is simple. You create a group. You add tasks to the group. You wait for the group to finish. If a task returns an error, the group stops waiting and signals cancellation to all other tasks. The tasks must respect that signal. errgroup does not kill goroutines. It provides a context that cancels. Your code must check the context and stop working when it sees the cancellation.

Think of a construction crew. The foreman assigns tasks to workers. If one worker finds a gas leak, the foreman blows a whistle. The workers hear the whistle and stop what they are doing. The foreman then reports the leak. The workers are responsible for stopping when they hear the whistle. The foreman cannot force them to stop if they are wearing earplugs. In Go, the context is the whistle. Your goroutines must listen to it.

Minimal example

Here is the skeleton: create a group with context, add tasks, and wait for the result.

package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
)

func main() {
	// Start with a background context.
	ctx := context.Background()
	// errgroup.WithContext returns a group and a derived context.
	// The derived context cancels automatically when any task returns an error.
	g, ctx := errgroup.WithContext(ctx)

	// Add three tasks to the group.
	for i := 0; i < 3; i++ {
		// Capture loop variable to avoid the classic closure bug.
		i := i
		g.Go(func() error {
			// Check if the context is already cancelled before doing work.
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
				// Simulate work.
				fmt.Printf("Task %d running\n", i)
			}
			return nil
		})
	}

	// Wait blocks until all tasks finish or one returns an error.
	// If an error occurs, the context cancels and remaining tasks stop.
	if err := g.Wait(); err != nil {
		fmt.Printf("Group failed: %v\n", err)
	}
}

errgroup.WithContext is the standard entry point. It returns two values: the group itself and a context derived from the input context. The derived context is special. It cancels automatically when the first goroutine added to the group returns a non-nil error. This is the mechanism that enables coordinated cancellation.

The Go method accepts a function that returns an error. It spawns a goroutine to run that function. The function runs concurrently with other tasks in the group. If the function returns an error, errgroup captures it. The Wait method blocks the caller until all goroutines finish. If any goroutine returned an error, Wait returns the first error encountered. If all goroutines return nil, Wait returns nil.

Convention aside: the receiver name for errgroup is almost always g. You will see g.Go and g.Wait in every Go codebase. Stick to g. It is short, clear, and matches the community standard. Also, gofmt is mandatory. Run your code through gofmt before sharing it. The community expects consistent formatting, and arguing about indentation wastes time that could go into logic.

Goroutines are cheap. Cancellation is explicit.

What happens at runtime

When you call errgroup.WithContext, the package creates a context that wraps your input context. It also sets up internal state to track errors and cancellation. The context starts in a non-cancelled state.

When you call g.Go, the package launches a new goroutine. That goroutine runs your function. If your function returns an error, the goroutine signals the group. The group records the error if it is the first one. Then the group cancels the derived context. Cancelling the context triggers the Done channel. Any goroutine listening to ctx.Done() will see the signal and can exit.

The Wait method blocks the caller. It waits for all goroutines to finish. If an error was recorded, Wait returns that error. If no error occurred, Wait returns nil. The caller can then handle the error or proceed with success.

The key insight is that errgroup does not stop goroutines. It only cancels the context. Your goroutines must check the context and stop. If a goroutine ignores the context, it continues running even after the group has failed. This is a goroutine leak. The goroutine will eventually finish, but it wastes resources and may cause unexpected behavior. Always check the context in long-running tasks.

Convention aside: context.Context always goes as the first parameter in Go functions. The parameter is conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. If you write a helper function for your tasks, pass the context as the first argument. This makes it easy to thread cancellation through your code.

// DoWork performs a task using the provided context.
func DoWork(ctx context.Context, id int) error {
	// Check context before starting expensive work.
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}
	// Perform work here.
	return nil
}

Trust the context. Run it through every long-lived call site.

Realistic example: fetching URLs

Here is a realistic scenario: fetching data from multiple URLs in parallel. If any fetch fails, cancel the rest and report the error.

package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/http"
	"golang.org/x/sync/errgroup"
)

// FetchURL retrieves content from a URL using the provided context.
func FetchURL(ctx context.Context, url string) ([]byte, error) {
	// Create request with context to support cancellation.
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	// Read body, respecting context cancellation.
	return io.ReadAll(resp.Body)
}

func main() {
	urls := []string{
		"https://httpbin.org/delay/1",
		"https://httpbin.org/status/500",
		"https://httpbin.org/delay/2",
	}
	ctx := context.Background()
	// Create group with context for automatic cancellation on error.
	g, ctx := errgroup.WithContext(ctx)

	for _, u := range urls {
		// Capture loop variable for closure safety.
		u := u
		g.Go(func() error {
			data, err := FetchURL(ctx, u)
			if err != nil {
				// Wrap error with context for debugging.
				return fmt.Errorf("fetch %s: %w", u, err)
			}
			fmt.Printf("Got %d bytes from %s\n", len(data), u)
			return nil
		})
	}

	// Wait for all fetches or first error.
	if err := g.Wait(); err != nil {
		log.Fatalf("Group failed: %v", err)
	}
}

The FetchURL function accepts a context and uses http.NewRequestWithContext. This ensures the HTTP request respects cancellation. If the context cancels, the request aborts. The io.ReadAll call also respects context cancellation. If the context cancels while reading, ReadAll returns an error.

In main, we create the group and iterate over URLs. We capture the loop variable u to avoid the closure bug. Each task calls FetchURL with the group's context. If FetchURL returns an error, we wrap it with fmt.Errorf and %w. Wrapping preserves the original error chain, which helps with debugging. The group returns the first wrapped error.

Convention aside: if err != nil { return err } is verbose by design. The Go community accepts this boilerplate because it makes the unhappy path visible. Do not hide errors. Check them immediately. Wrapping errors with %w allows callers to use errors.Is or errors.As to inspect the chain. This is the standard error handling pattern in Go.

Wrap errors at the boundary. Let the group bubble up the first failure.

Bounding concurrency with SetLimit

Sometimes you have many tasks but want to limit how many run at once. This protects downstream services from overload. errgroup provides SetLimit for this purpose.

// Create group with context.
g, ctx := errgroup.WithContext(ctx)
// Limit to 5 concurrent goroutines.
g.SetLimit(5)

for i := 0; i < 100; i++ {
	i := i
	g.Go(func() error {
		// Task runs, but only 5 at a time.
		return nil
	})
}

SetLimit caps the number of goroutines that can run concurrently. If you call Go while the limit is reached, the call blocks until a slot opens. This creates a worker pool effect without managing channels manually. The limit applies to the number of active goroutines, not the number of tasks. Tasks queue up and run as slots become available.

Use SetLimit when you need to protect a downstream service from too many concurrent requests. It is simpler than building a channel-based worker pool. The group still handles cancellation and error aggregation.

Convention aside: receiver names in Go are usually one or two letters matching the type. You will see (b *Buffer) Write(...) but not (this *Buffer). For errgroup, g is the standard name. Stick to short, descriptive names. It keeps code readable.

Bounding concurrency prevents overload. Use limits when the downstream cannot handle the blast.

Pitfalls and compiler errors

Loop variable capture is the most common mistake. In Go versions before 1.22, the compiler allowed closures to capture the loop variable by reference. This caused all goroutines to see the final value of the variable. Go 1.22 changed this behavior to create a new variable per iteration, but the compiler still warns if you capture a loop variable in a way that suggests the old bug. The compiler rejects the program with loop variable i captured by func literal if you trigger the warning in strict mode. The fix is to capture the variable explicitly: i := i. This works in all Go versions and makes the intent clear.

Another pitfall is ignoring the context. If a goroutine does not check ctx.Done(), it continues running after the group cancels. This is a goroutine leak. The goroutine will eventually finish, but it wastes resources. Always check the context in long-running tasks. Use select with ctx.Done() to allow early exit.

Using errgroup without WithContext is a subtle mistake. errgroup has a Go method that does not involve context. If you use errgroup directly, you get error aggregation but no automatic cancellation. You must manage cancellation yourself. Use errgroup.WithContext unless you have a specific reason to disable cancellation. The context version is the safe default.

Compiler errors are plain text. If you forget to import a package, you get undefined: pkg. If you import a package and do not use it, you get imported and not used. The compiler is strict about unused imports. Remove them or use the blank identifier _ to discard values intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use _ sparingly with errors. Dropping errors silently is usually a bug.

The worst goroutine bug is the one that never logs. Always respect cancellation.

When to use errgroup

Use errgroup.WithContext when you need to cancel all tasks if any single task fails. Use errgroup without context when you want to collect errors but let all tasks finish regardless of failures. Use sync.WaitGroup when you need to wait for goroutines but do not need error aggregation or automatic cancellation. Use sequential code when the tasks are fast or dependent on each other, because concurrency adds complexity without benefit. Use errgroup.SetLimit when you need to bound concurrency to protect a downstream service. Use channels when you need to stream data between goroutines in a pipeline.

errgroup is a coordination primitive. It handles the plumbing. You handle the work.

Where to go next