Day 27 — errgroup

Updated

July 30, 2026

Day 27 — errgroup

Stage III · ~3h (theory-heavy)
Goal: Use golang.org/x/sync/errgroup for structured concurrent work: wait for a group, return the first error, cancel siblings, and bound parallelism with SetLimit.

Note

errgroup is not in the standard library—it lives in golang.org/x/sync. It is the de-facto companion for production Go concurrency.

Why this day exists

Manual patterns from Day 26 work, but:

  • Error channels are easy to mishandle
  • Cancel + WaitGroup boilerplate repeats
  • Limits are reinvented poorly

errgroup.Group encodes a battle-tested contract:

Run N functions; wait; return the first non-nil error; optionally cancel the rest.


Theory 1 — Module setup

go get golang.org/x/sync/errgroup
import "golang.org/x/sync/errgroup"

Pin versions via go.mod like any other dependency. Keep x/sync reasonably current; API used here is stable.


Theory 2 — Basic Group

var g errgroup.Group

g.Go(func() error {
    return taskA()
})
g.Go(func() error {
    return taskB()
})

if err := g.Wait(); err != nil {
    return err
}

Rules:

  1. Go starts a goroutine immediately.
  2. Wait blocks until all finish.
  3. First non-nil error is returned; others are discarded (they still run unless using cancel context).
  4. If a function panics, Wait panics with the same value (don’t use panic for control flow).

Without a derived context, siblings keep running after the first error—they just don’t change the returned error.


Theory 3 — WithContext for fail-fast cancel

g, ctx := errgroup.WithContext(parent)

g.Go(func() error {
    return work(ctx, a)
})
g.Go(func() error {
    return work(ctx, b)
})

err := g.Wait() // ctx cancelled when first Go func returns error

Semantics:

  • WithContext returns a child context cancelled when the first Go function returns a non-nil error or when Wait returns
  • Pass ctx into all blocking ops
  • After Wait, resources associated with the group are done

This is the structured form of Day 26 fail-fast.


Theory 4 — SetLimit

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // max concurrent Go funcs running

for _, item := range items {
    item := item
    g.Go(func() error {
        return process(ctx, item)
    })
}
if err := g.Wait(); err != nil {
    return err
}
  • SetLimit(n) caps concurrent goroutines inside the group
  • Additional Go calls block until a slot frees (or panic if limit is 0? — limit must be positive; 0 means unlimited historically—check docs for your version; use positive n)
  • Call SetLimit before Go

This replaces many hand-rolled worker pools for the “run this function per item” shape.


Theory 5 — What errgroup is not

Not for Prefer
Streaming pipelines with continuous channels Channel stages
Long-lived worker servers Dedicated pool + lifecycle
Collecting all errors Mutex slice + errors.Join, or custom
Shared mutable aggregation without sync Still need mutex/atomic

Wait returns one error. For multierror:

var (
    mu   sync.Mutex
    errs []error
)
// inside Go: on error append under mu; return nil to not cancel
// after Wait: errors.Join(errs...)

Or return nil from workers and join manually—know you give up fail-fast cancel unless you cancel yourself.


Theory 6 — Panic and recovery policy

Prefer errors over panics. If a dependency panics:

  • errgroup will surface panic on Wait
  • Recover inside Go only if you have a clear policy:
g.Go(func() error {
    defer func() {
        if r := recover(); r != nil {
            // convert to error if required by policy
        }
    }()
    return risky()
})

Usually: fix the panic source.


Worked example — parallel fetch with limit

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"

    "golang.org/x/sync/errgroup"
)

func headOK(ctx context.Context, url string) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
    if err != nil {
        return err
    }
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return fmt.Errorf("%s: %w", url, err)
    }
    res.Body.Close()
    if res.StatusCode >= 400 {
        return fmt.Errorf("%s: status %d", url, res.StatusCode)
    }
    return nil
}

func checkAll(ctx context.Context, urls []string, limit int) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(limit)
    for _, u := range urls {
        u := u
        g.Go(func() error {
            return headOK(ctx, u)
        })
    }
    return g.Wait()
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    urls := []string{
        "https://example.com",
        "https://example.com/404", // may fail
    }
    if err := checkAll(ctx, urls, 4); err != nil {
        fmt.Println("failed:", err)
        return
    }
    fmt.Println("all ok")
}

Use -race in tests; live network optional—prefer fake servers (httptest) in automated tests.


Worked example — pure CPU with cancel

func squares(ctx context.Context, n int) ([]int, error) {
    out := make([]int, n)
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(runtime.GOMAXPROCS(0))

    for i := 0; i < n; i++ {
        i := i
        g.Go(func() error {
            if err := ctx.Err(); err != nil {
                return err
            }
            if i == n/2 {
                return fmt.Errorf("boom at %d", i)
            }
            out[i] = i * i // each index exclusive — no race
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return out, nil
}

Index-exclusive writes need no mutex.


Lab 1 — Install and basic Wait

Workspace: ~/lab/90daysofx/01-go/day27

mkdir -p ~/lab/90daysofx/01-go/day27 && cd ~/lab/90daysofx/01-go/day27
go mod init example.com/day27
go get golang.org/x/sync/errgroup
  1. Run three tasks: two succeed, one fails.
  2. Print error from Wait.
  3. Race-clean.
go test -race ./...

Lab 2 — WithContext fail-fast

  1. Tasks sleep 50ms, 100ms, 200ms.
  2. The 50ms task returns an error.
  3. Instrument longer tasks to exit early via ctx.Done().
  4. Assert they did not run to full sleep (timing with tolerance).

Lab 3 — SetLimit correctness

Spawn 100 tasks each holding a critical section counter:

var current atomic.Int64
var max atomic.Int64
// on enter: c := current.Add(1); update max; sleep; current.Add(-1)

SetLimit(5) ⇒ observed max concurrent ≤ 5. Race-clean.


Lab 4 — Refactor Day 23/24 pool to errgroup

Replace hand-rolled worker pool for batch jobs with errgroup + SetLimit. Keep context timeout. Compare line count and clarity in journal.

Optional: multierror mode that does not cancel—document tradeoff.


Common gotchas

Gotcha Fix
Forgetting to use returned ctx All work must observe it
SetLimit after many Go Set limit first
Capturing loop variable wrong item := item before Go
Assuming all errors returned Only first; design multierror explicitly
Mixing panic with Wait Convert or prevent panics
Not go get x/sync Module dependency required

Checkpoint

  • Added golang.org/x/sync/errgroup to a module
  • Explains Group vs WithContext
  • Uses SetLimit and proved concurrency bound
  • Fail-fast lab shows sibling cancellation
  • Refactored one older pool toward errgroup
  • go test -race ./... clean

Commit

git add .
git commit -m "day27: errgroup WithContext + SetLimit labs"

Write three personal gotchas before continuing.


Tomorrow

Day 28 — Memory model lite: happens-before, what races really mean, and documenting synchronization so future you trusts the code.