How to Use errgroup for Concurrent Tasks in Go

Use errgroup.WithContext and g.Go to run concurrent tasks that cancel automatically on the first error.

How to Use errgroup for Concurrent Tasks in Go

You have three independent tasks running in goroutines. One of them crashes. The other two keep running, burning CPU and memory, while you try to figure out why the first one failed. You want the whole batch to fail fast. You want the error from the failed task to bubble up to your caller. You also want a clean way to cancel the remaining work so nothing leaks.

This is the "fan-out, fan-in" problem with error handling. You spawn parallel work, wait for results, and propagate failures. Go's standard library gives you sync.WaitGroup for waiting and context for cancellation, but stitching them together with error channels is repetitive. errgroup solves this. It bundles goroutines, errors, and cancellation into a single primitive.

The problem with a bag of goroutines

Without errgroup, you manage a WaitGroup, a channel for errors, and a context manually. You have to ensure the error channel doesn't block if multiple goroutines fail. You have to cancel the context when the first error arrives. You have to make sure every goroutine checks for cancellation. The boilerplate grows quickly and introduces race conditions if you miss a detail.

errgroup removes the ceremony. It tracks the first error and cancels the shared context automatically. You call g.Go for each task and g.Wait to block until completion or failure. The package handles the coordination. It lives in golang.org/x/sync, not the standard library, but it is stable and widely used as the de facto standard for this pattern.

Think of errgroup like a project manager running a parallel review. If any reviewer finds a blocker, the manager stops the meeting for everyone and reports the blocker. The other reviewers don't waste time finishing their work. The group shares a single fate.

Minimal example

Here's the skeleton. Create a group, loop over tasks, and wait for the result.

package main

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

func main() {
	// WithContext creates a group and a derived context.
	// The context carries cancellation signals across all tasks.
	ctx := context.Background()
	g, ctx := errgroup.WithContext(ctx)

	for i := 0; i < 3; i++ {
		// Capture loop variable to avoid the classic closure bug.
		// Go 1.22+ fixes this, but capturing is safe everywhere.
		i := i
		g.Go(func() error {
			// Simulate work that might fail.
			// In real code, this is an API call or DB query.
			select {
			case <-time.After(100 * time.Millisecond):
				// Return an error to trigger group cancellation.
				return fmt.Errorf("task %d failed", i)
			case <-ctx.Done():
				// Propagate cancellation if another task failed.
				return ctx.Err()
			}
		})
	}

	// Wait blocks until all tasks finish or one returns an error.
	// It returns the first error, or nil if all succeed.
	if err := g.Wait(); err != nil {
		fmt.Println("Error:", err)
	}
}

Run this and you get:

# output:
Error: task 0 failed

The group returns the first error. The other tasks see the context cancel and return quickly. gofmt handles the indentation here. Trust gofmt. Argue logic, not formatting. Most editors run it on save, so your code looks consistent without debate.

How it works under the hood

When you call errgroup.WithContext, the package creates a shared error channel and a cancellable context. Every call to g.Go starts a goroutine that runs your function. Inside that function, you must check ctx.Done. If another goroutine returns an error, the group cancels the context. Your function sees the cancellation and returns.

g.Wait blocks until every goroutine has returned. It collects the first error and returns it. If all functions return nil, g.Wait returns nil. The package ensures that only one error is reported. If multiple tasks fail simultaneously, you get one of them. This is a design choice. errgroup is for "fail fast" scenarios where one failure invalidates the whole operation.

Context is plumbing. Run it through every long-lived call site. If you forget to pass the context to a helper function, that helper won't see the cancellation. The group cancels the context, but your code must respect it.

Realistic example: HTTP handler

Real code usually passes context to functions. Here's a handler that fetches user data and orders in parallel. If either call fails, the other is cancelled and the handler returns an error.

package main

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

// FetchUser simulates a database call.
// Context is the first parameter by convention.
func FetchUser(ctx context.Context, id int) (string, error) {
	// Check cancellation before expensive work.
	select {
	case <-ctx.Done():
		return "", ctx.Err()
	default:
	}
	// Simulate latency.
	// In production, pass ctx to the DB driver.
	time.Sleep(50 * time.Millisecond)
	return fmt.Sprintf("User-%d", id), nil
}

// FetchOrders simulates another service call.
func FetchOrders(ctx context.Context, id int) ([]string, error) {
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}
	time.Sleep(50 * time.Millisecond)
	return []string{"Order-A", "Order-B"}, nil
}

func handleDashboard(w http.ResponseWriter, r *http.Request) {
	// Derive context from request.
	// This ties the work to the HTTP lifecycle.
	ctx := r.Context()
	g, ctx := errgroup.WithContext(ctx)

	var userName string
	var orders []string

	// Task 1: Fetch user.
	g.Go(func() error {
		name, err := FetchUser(ctx, 42)
		if err != nil {
			return err
		}
		userName = name
		return nil
	})

	// Task 2: Fetch orders.
	g.Go(func() error {
		orderList, err := FetchOrders(ctx, 42)
		if err != nil {
			return err
		}
		orders = orderList
		return nil
	})

	// Wait for both.
	// If either fails, the other is cancelled.
	if err := g.Wait(); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	fmt.Fprintf(w, "Hello %s, you have %d orders", userName, len(orders))
}

Notice ctx is the first parameter in FetchUser and FetchOrders. Context is always the first parameter. This convention lets tools and readers spot the cancellation path immediately. The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally drop an error without writing code to do so.

Don't pass a *string to functions. Strings are cheap to pass by value. Pointers add allocation pressure without benefit. Use string directly unless you need to mutate the value in place, which is rare.

Pitfalls and gotchas

Loop variable capture. In Go 1.21 and earlier, capturing i in a loop creates a closure over the same variable. All goroutines see the final value. Go 1.22 changed this, but capturing i := i is still safe. The compiler used to warn with loop variable i captured by func literal. If you forget to capture in older versions, your tasks might all use the wrong index.

Forgetting context checks. If you don't check ctx.Done, your goroutine might keep running after cancellation. This wastes resources. The group cancels the context, but your code must respect it. The worst goroutine bug is the one that never logs. If a goroutine leaks, it holds memory and file descriptors until the process exits.

Error aggregation. errgroup returns one error. If you need to report all failures, errgroup isn't the tool. Use a channel to collect errors or a slice with a mutex. errgroup is for operations where one failure means the whole batch is useless.

Panics. errgroup does not recover from panics. If a goroutine panics, the program crashes. Wrap risky code in defer recover if needed, though panics usually indicate a bug. Return errors instead of panicking.

Compiler errors. Forget to import golang.org/x/sync/errgroup and you get undefined: errgroup. Forget to use it and you get imported and not used. The compiler rejects these immediately. Discarding an error with _ signals intent, but use it sparingly. If you drop an error, you risk hiding a failure. result, _ := ... says "I considered the second return value and chose to drop it".

Limiting concurrency with semaphore

errgroup doesn't limit concurrency. If you spawn 10,000 goroutines, you create 10,000 goroutines. This can overwhelm downstream services or exhaust memory. Combine errgroup with semaphore.Weighted to bound the number of active tasks.

Here's how to limit concurrency to 10 tasks.

package main

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

func main() {
	ctx := context.Background()
	g, ctx := errgroup.WithContext(ctx)

	// Create a semaphore with weight 10.
	// This allows at most 10 concurrent acquisitions.
	sem := semaphore.NewWeighted(10)

	for i := 0; i < 100; i++ {
		i := i
		g.Go(func() error {
			// Acquire a slot before doing work.
			// This blocks if 10 tasks are already running.
			if err := sem.Acquire(ctx, 1); err != nil {
				return err
			}
			// Release the slot when the task finishes.
			defer sem.Release(1)

			// Do work here.
			// Context cancellation works as usual.
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
			}
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		fmt.Println("Error:", err)
	}
}

The semaphore blocks goroutines until a slot is available. The group still waits for all tasks and propagates errors. This pattern is common when calling external APIs with rate limits. Public names start with a capital letter. semaphore is a package name, so it's lowercase. NewWeighted is a function, so it's capitalized. This visibility rule applies to all exported identifiers.

When to use errgroup

Use errgroup when you need to run multiple independent tasks and fail fast on the first error. Use errgroup when you want automatic cancellation propagation across a set of goroutines. Use sync.WaitGroup when you need to wait for goroutines but don't care about errors or cancellation. Use plain goroutines with channels when you need to aggregate results from all tasks, even if some fail. Use sequential code when the tasks are fast or dependent on each other. Use errgroup with semaphore when you need bounded concurrency to protect a downstream service.

errgroup is for parallel work that shares a single fate.

Where to go next