← Files GophersARCHIVED FILE

skills/go-concurrency/references/errgroup-and-pools.md

3.59 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

# errgroup, Worker Pools, and Bounded Concurrency

`golang.org/x/sync/errgroup` is the default tool for "run N things, wait for them, fail fast". It replaces almost every hand-rolled worker pool.

## errgroup basics

```go
g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
    g.Go(func() error {
        return fetch(ctx, u)
    })
}
if err := g.Wait(); err != nil {
    return fmt.Errorf("fetch: %w", err)
}
```

`Wait` returns the **first** non-nil error. As soon as any worker fails, `ctx` is cancelled, so the others can short-circuit.

## Bounded concurrency

`SetLimit(n)` turns the group into a worker pool. `g.Go` blocks once `n` goroutines are running:

```go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(runtime.GOMAXPROCS(0)) // CPU-bound work

for _, item := range items {
    g.Go(func() error { return compute(ctx, item) })
}
return g.Wait()
```

`SetLimit` must be called before the first `g.Go`.

## TryGo for opportunistic work

`g.TryGo(fn)` returns `false` if the limit is reached, letting the caller fall back to inline execution or drop the work. Useful for fire-and-forget enrichment.

## errgroup vs sync.WaitGroup

| Need | Use |
|---|---|
| Fire-and-forget goroutines, no errors | `sync.WaitGroup` (or `wg.Go` in Go 1.25+) |
| First-error semantics, mutual cancellation | `errgroup.WithContext` |
| Bounded concurrency | `errgroup.SetLimit` |
| Collect *all* errors | Use `errgroup` with `errors.Join` or a slice + mutex |

## Collecting all errors

`errgroup` returns the first error. To gather every failure, capture them in the worker and join at the end:

```go
var (
    mu   sync.Mutex
    errs []error
)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
    g.Go(func() error {
        if err := work(ctx, item); err != nil {
            mu.Lock(); errs = append(errs, err); mu.Unlock()
        }
        return nil // do not cancel siblings
    })
}
_ = g.Wait()
return errors.Join(errs...)
```

Note: returning `nil` here disables errgroup's cancellation. That is intentional — we want every item attempted.

## Hand-rolled worker pool (when errgroup isn't enough)

A bounded semaphore using a buffered channel works when you need custom result aggregation:

```go
sem := make(chan struct{}, workers)
results := make(chan Result, len(items))
var wg sync.WaitGroup

for _, item := range items {
    wg.Add(1)
    sem <- struct{}{}
    go func(it Item) {
        defer wg.Done()
        defer func() { <-sem }()
        results <- process(ctx, it)
    }(item)
}
go func() { wg.Wait(); close(results) }()

for r := range results { /* aggregate */ }
```

This is more code than `errgroup.SetLimit`; prefer the latter unless the result fan-in needs custom handling.

## Pipelines with errgroup

Each stage runs as a `g.Go` and communicates over channels. The first error cancels `ctx`, which every stage selects on, so all stages drain and exit cleanly:

```go
g, ctx := errgroup.WithContext(ctx)
stage1 := make(chan A)
stage2 := make(chan B)

g.Go(func() error { defer close(stage1); return read(ctx, stage1) })
g.Go(func() error { defer close(stage2); return transform(ctx, stage1, stage2) })
g.Go(func() error { return write(ctx, stage2) })

return g.Wait()
```

## Anti-patterns

| Anti-pattern | Fix |
|---|---|
| Manual `for { sem <- struct{}{} ... }` when limit + error are all you need | `errgroup.SetLimit` |
| Calling `g.SetLimit` after `g.Go` | Call it before the first goroutine starts |
| Returning a non-nil error you do not want to propagate | Log and return `nil` to keep siblings running |
| Using `errgroup` without `WithContext` for cancellation | You lose the fail-fast property |

SHA-256: 1cf03a074f51a53dbc93d4632b085bd09b3332bee94bc7f4f4af839239964de9