Chapter 14: Error Handling in Concurrent Code

Go’s error contract is one sentence: if a function can fail, it returns an error, and the caller sees it. Twelve chapters have relied on that. Chapter 13 ended by returning ctx.Err() and leaving it there.

The go keyword breaks the contract. It does not discard the error in some subtle way that a linter could catch — it discards every return value the function produces, because a caller that does not wait has no moment at which to receive them. result, err := go compute() is not merely bad style; it is a syntax error, and it has to be, since the two halves contradict each other.

So every goroutine you start opens a hole in the error chain, and closing it is your job. Here are three attempts. All three compile, go vet is happy with all three, and go test -race reports nothing on any of them.

snippet_14_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the error has nowhere to go, so the caller lies
for _, order := range orders {
    go process(order) // returns an error; nothing receives it
}
time.Sleep(time.Second)
fmt.Println("all orders processed successfully")
err_ch_14_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: one slot, ten senders, and an early return
errCh := make(chan error, 1)
for i := 0; i < 10; i++ {
    go func() { errCh <- doWork() }()
}
if err := <-errCh; err != nil {
    return err // eight goroutines are still parked on the send
}
g_14_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the panic is caught and the error is thrown away
g.Go(func() error {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("recovered: %v", r) // cannot set the return
        }
    }()
    return processItem(ctx, item)
})

The first is the problem this chapter exists to solve: the program reports success for work that failed. The second is the buffer-sizing rule and an off-by-one that most people get wrong — the leak is eight goroutines, not nine, and §14.2 measures it. The third is the missing named return, which converts a caught panic into a silent nil and tells errgroup the goroutine succeeded.

Three mechanisms close the hole, and the rest of the chapter is about choosing between them and composing them.

THE SEVERED RETURN PATH

Two call shapes side by side. In the sequential one the caller calls doWork, blocks, and receives a value and an error on the way back, which it handles. In the concurrent one the caller starts doWork with go and continues immediately; the return values have nowhere to go and are discarded. Return values require the caller to wait and go means do not wait, so the two cannot both hold. Three mechanisms rebuild the path by hand: a plain error channel for errors alone, a Result channel carrying a value and an error together, and errgroup, which reports the first error and cancels the rest.

What you’ll learn
  • Why go cannot return an error, and the three shapes of the return path you build instead
  • How to size an error channel’s buffer, what an undersized one costs, and the two receive loops that go with it
  • When a result struct beats a plain error channel, and how to keep order without a mutex
  • Every part of errgroupWithContext, SetLimit, TryGo — what g.Wait really returns, and why the package is cheaper than the pattern it replaces
  • The three pipeline error strategies, and the two mechanisms that together unwind a pipeline from any stage
  • How to convert a panic to an error, what debug.Stack() actually captures, and when recovering is the wrong call
  • How to design for partial failure: classification, quorum, deadlines, fallback, and making degradation visible
What we’re not covering
  • Graceful shutdown of a whole process — Chapter 15, which builds on §14.4's group lifecycle
  • Testing concurrent error paths, goleak, and race-detector integration — Chapter 16
  • Rate limiting, backpressure and circuit breakers — Chapter 17
  • The bug catalogue — Chapter 18 collects the leaks and races this chapter warns about
  • Context itself — Chapter 13. This chapter uses ctx.Done(), ctx.Err() and context.Cause as given
Building toward

Chapter 13 gave you the mechanism to stop a tree of goroutines. This chapter puts a reason on the stop and gets it back to the code that can act on it. Chapter 15 turns both into an orderly exit.

Prerequisites

Channels and closing semantics from Chapter 3, since every mechanism here is a channel underneath. The select statement from §4.2, because a guarded send is what keeps these patterns from leaking. Buffered channels from §5.2 — the buffer-size rule in §14.2.2 is that section’s rule applied to errors. The pipeline and fan-out shapes from Chapter 7, which §14.5 adds errors to. sync.Once and sync.WaitGroup from Chapter 12, because §14.4.8 opens errgroup and finds both. Deadlock from §10.2, since §14.2.2's second failure mode is one. And context cancellation from Chapter 13 throughout.

Which Go are we on?

Every listing and figure in this chapter was run on Go 1.25 or later and checked against Go 1.27, against golang.org/x/sync v0.22.0 (v0.23.0 at the time of this revision; the errgroup API used here is unchanged). Four things matter when comparing this with older writing. Go 1.20 added errors.Join, which is how a collect-all strategy returns every failure as one error, and context.WithCancelCause, which errgroup now uses internally — §14.4.8 shows what that buys you. Go 1.22 made loop variables per-iteration, so the url := url line that older errgroup examples all carry is gone from this chapter. Go 1.25 added sync.WaitGroup.Go, used here wherever a WaitGroup appears. And errgroup itself is not frozen: SetLimit and TryGo arrived in 2022, so treat any example without them as pre-dating that.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16, golang.org/x/sync v0.22.0. Every figure came from running the benchmark as printed, -benchtime 300ms -count 5, minimum of five passes, -cpu 1 unless stated. Allocation counts reproduced exactly across two independent harnesses; the nanosecond figures moved by 10–20% between them. Where the two disagree, this chapter leads with the allocations and treats the timings as approximate — that is the durable half.

14.1 The Fundamental Problem

In sequential code the error path is the return path, and it is always there. In concurrent code it is neither.

14.1.1 The go Keyword Severs the Error Chain

compute_141.go
// Illustrative snippet — not a complete program
func compute() (int, error) { return 42, nil }

result, err := compute() // blocks; both values arrive
go compute()             // compiles; (42, nil) is discarded

The values are not hidden somewhere for later retrieval. The runtime never captures them, because there is no frame waiting to receive them. This is not an oversight in the language design; it follows from what go means. Return values require the caller to wait. go is the statement that says do not wait. You cannot have both, so Go gives you the second and leaves the first to you.

Three consequences follow, and none of them exist in sequential code:

14.1.2 The Silent Failure

Here is the failure mode that makes this worth a chapter:

main_141.go
// Illustrative snippet — not a complete program
func main() {
    records := []string{"order-1", "order-2", "order-3"}
    for _, id := range records {
        go process(id)
    }
    time.Sleep(time.Second) // not production-safe; see §2.3
    fmt.Println("all orders processed successfully")
}

func process(id string) error {
    if id == "order-2" {
        return errors.New("payment gateway timeout")
    }
    fmt.Printf("  ok %s processed\n", id)
    return nil
}

Two of the three lines print, in an order that changes between runs, and then the program announces complete success:

Terminal
  ok order-3 processed
  ok order-1 processed
all orders processed successfully
Measured the two ok lines arrive in an order the scheduler picks. Thirty runs on the reference machine gave order-3 first twenty-six times and order-1 first four times. Nothing in the program orders them, and nothing should be written to depend on it — the point of the listing is the third line, which is the same every time and is a lie.

process returned an error to nobody. The program did not crash, did not log, did not return a non-nil error to its caller. It reported success for work that failed, which is the most expensive kind of bug: it compounds silently, and the incident arrives days later attached to the wrong symptom.

Question 3, from §2.4

The four questions ask, of every goroutine you start: how does it exit, who waits for it, how are errors handled, and what does it share? For the code above the honest answer to the third is “they aren’t.” If you cannot answer it before you type go, you are not ready to type go.

14.1.3 What Doesn’t Work

Four things look like answers and are not.

Logging instead of propagating. log.Printf("failed: %v", err) inside the goroutine records the failure for a human reading later. The calling code still needs to retry, return 500, or fall back — and it cannot, because nothing told it. Logging is observability; it is not error handling.

Panicking. A panic in a goroutine is not contained to that goroutine. Unless it is recovered on that same stack, it takes the whole process down (§14.6). This trades a silent failure for a catastrophic one.

A shared error variable. lastErr = err from several goroutines is a data race (§8.2), and it keeps one error out of however many occurred.

Ignoring it. go process(order) compiles. Networks fail, disks fill, services restart. The errors happen whether or not you built somewhere for them to go.

14.1.4 Three Solutions

Each of the three builds the return path in a different shape.

Error channels (§14.2). A dedicated chan error. The goroutine sends, the caller receives. No dependencies, no new types, and full control over how many errors you collect and what you do with them. Best when the goroutine’s job is a side effect and the only question is whether it worked.

err_ch_141.go
// Illustrative snippet — not a complete program
errCh := make(chan error, 1)
go func() { errCh <- sendEmail(to, "Welcome!") }()
if err := <-errCh; err != nil {
    return fmt.Errorf("welcome email: %w", err)
}

Result structs (§14.3). One struct carrying the value, the error, and an identifier, sent on one channel. Best when the goroutine produces something the caller needs, and when you need to know which goroutine produced which outcome.

fetch_result_141.go
// Illustrative snippet — not a complete program
type FetchResult struct {
    URL  string
    Body []byte
    Err  error
}

ch := make(chan FetchResult, len(urls))

errgroup (§14.4). golang.org/x/sync/errgroup wraps a WaitGroup, a sync.Once and a cancel function into three calls. Best when you want the first error to stop the rest.

g_141.go
// Illustrative snippet — not a complete program
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return sendEmail(ctx, to, "Welcome!") })
g.Go(func() error { return sendSMS(ctx, num, "Welcome!") })
if err := g.Wait(); err != nil {
    return err
}

14.1.5 Choosing the Right Mechanism

Criteria
Returns values as well as errors
Cancel the rest on first error
Concurrency limit
Which errors you get
Dependencies
CHOOSING A MECHANISM

A decision tree with one question at the root: does the goroutine produce a value you need? If it does, use a result struct carrying identity, value and error. If it does not, three branches follow: one or two goroutines take a plain error channel; a batch where the first error should stop the rest takes errgroup; long-lived workers reporting errors as they go take an error channel again. The three compose, and errgroup for the lifecycle with a result slice for the output is the most common production shape.

The three are not exclusive, and the table is not a decision you make once. Production code routinely uses errgroup for coordination with result structs for output, which is why §14.4.6 exists.

14.1.6 Common Mistakes

go doWork() with no return path
Problem

The caller reports success for failed work

Fix

A channel, a result struct, or errgroup

Logging the error inside the goroutine
Problem

Failure is recorded but nothing reacts to it

Fix

Send it back; log at the place that decides

Writing errors to a shared variable
Problem

Data race, and only one error survives

Fix

Per-index writes, or a channel

Panicking to signal failure
Problem

One bad input kills the process

Fix

Return the error; recover only at a boundary (§14.6)

Summary: The Fundamental Problem

The go keyword discards return values because a caller that does not wait cannot receive them. That is not a flaw to work around but the definition of the statement, and it means every goroutine that can fail needs an explicit path back to code that can act on the failure.

The failure mode is silence. Nothing crashes and nothing logs; the program simply reports an outcome that did not happen. Logging inside the goroutine does not fix it, because logs are read by people afterwards and the caller needs to react now.

Three mechanisms build the path. An error channel carries errors only. A result struct carries a value, an error and an identity together. errgroup carries the first error and cancels the rest. Which one you want depends on whether the goroutine produces a value, how many goroutines there are, and whether one failure should stop the others.

Self-Check Questions: The Fundamental Problem

Why can’t you write result, err := go doWork()?

Because the two halves of that line contradict each other.

Assignment from a call requires the caller to block until the callee returns, which is the only moment the values exist to be copied. The go keyword exists to say the caller will not block. There is therefore no point in time at which the assignment could happen.

It is a syntax error rather than a runtime one, which is the right call: the language refuses to let you write something that cannot mean anything, instead of accepting it and producing zero values.

A goroutine logs its error and returns. What is still wrong?

The caller cannot react.

Logging puts the failure somewhere a person will read minutes or days later. The decisions that depend on it — retry, fall back to a cache, return 500 rather than 200, roll the batch back — all have to be made now, by code, and that code was never told.

The two are not alternatives. Log where you have the context to describe the failure, and return it to whoever has the authority to decide what happens next. The mistake is treating the first as a substitute for the second.

You launch ten goroutines and three fail. What should the caller receive?

That is a design decision, and this chapter is largely about making it deliberately rather than by accident.

First-error-wins is right when any failure invalidates the batch — pre-flight checks, loading configuration where every section is required. Collect-all is right when the caller needs the full picture — a batch import where the user must fix every bad row, a multi-region deploy where you need to know which regions are down. Partial results are right when three successes have value even though two failed (§14.7).

What you cannot do is leave it undecided. The mechanism you pick encodes the answer: errgroup gives you the first error, a result channel gives you all of them, and a plain go statement gives you none.

Key Takeaways

  • go discards every return value, because a caller that does not wait has no moment at which to receive one
  • The failure mode is silence, not a crash — the program reports success for work that failed
  • Logging inside the goroutine is observability, not error handling; the caller still cannot react
  • A shared error variable is a data race and keeps only one of the failures
  • Three shapes build the path back: errors only, value-with-error, or first-error-plus-cancel
  • The choice turns on three questions — do you need a value, how many goroutines, and should one failure stop the rest
  • They compose; errgroup for the lifecycle with a result slice for the output is the common production shape
Section 14.1 — in one line

go cuts the wire that carries a result home, and nothing in the runtime will reconnect it — every goroutine that can fail needs a path you built on purpose.

14.2 Error Channels

The most direct answer to §14.1 is the one with no new concepts in it: give the goroutine a chan error and receive from it. Everything hard about this pattern is a sizing decision, and every sizing decision is about whether a send can block.

14.2.1 The Basic Pattern

send_welcome_142.go
// Illustrative snippet — not a complete program
func sendWelcomeEmail(to string) error {
    errCh := make(chan error, 1) // one sender, one slot

    go func() {
        errCh <- sendEmail(to, "Welcome!") // always sends
    }()

    prepareUserDashboard() // useful work while the mail goes out

    if err := <-errCh; err != nil {
        return fmt.Errorf("welcome email: %w", err)
    }
    return nil
}

Two decisions are doing all the work here.

Always send. The goroutine sends the result of sendEmail unconditionally — nil on success, an error on failure. The receiver does <-errCh, which blocks until something arrives. If the goroutine only sent on failure, a successful send of nothing would leave the caller blocked forever. One goroutine, one send, on every path.

Buffer of one. The goroutine can deposit its result and exit without waiting for anyone to collect it. Whether that matters depends on whether the receiver can leave early, which is the whole of §14.2.2.

14.2.2 Buffered vs Unbuffered

An unbuffered channel requires a sender and a receiver to meet. If the receiver can stop waiting — a select with another case, an early return, a timeout — the sender is left holding a value nobody will take.

Failure mode one: the leak.

check_endpoint_142_x_1.go
// Illustrative snippet — not a complete program
func checkEndpoint(ctx context.Context, url string) error {
    errCh := make(chan error) // ✗ unbuffered

    go func() {
        errCh <- httpPing(url) // blocks until someone receives
    }()

    select {
    case err := <-errCh:
        return err
    case <-ctx.Done():
        return ctx.Err() // we leave; the goroutine never can
    }
}

When the deadline fires first, checkEndpoint returns and stops receiving. The ping eventually completes, the goroutine reaches its send, and parks there permanently. One leaked goroutine per timeout, each holding its stack and whatever the request referenced.

Failure mode two: the deadlock.

process_142_x_1.go
// Illustrative snippet — not a complete program
// ✗ WRONG: unbuffered channel and a WaitGroup
func process(items []Item) error {
    var wg sync.WaitGroup
    errCh := make(chan error) // unbuffered

    for _, item := range items {
        wg.Go(func() {
            if err := processItem(item); err != nil {
                errCh <- err // blocks; wg.Done never runs
            }
        })
    }

    wg.Wait()  // waits for goroutines that are waiting for us
    close(errCh)
    return <-errCh
}

If nothing fails this runs fine, which is what makes it dangerous — it passes every test with valid input. The first real error deadlocks it. The goroutine blocks on the send before wg.Go's implicit Done can run, and the main goroutine is inside wg.Wait rather than receiving.

UNBUFFERED DEADLOCK

Three columns showing a circular wait. The main goroutine is blocked in wg.Wait. Two workers each hit an error and try to send it on an unbuffered error channel, where each send blocks because no receiver exists. Neither worker can reach its Done call, so the WaitGroup counter never falls and main never reaches the receive. Main waits for the workers and the workers wait for main. The fix is the same as for the leak: size the buffer so that every send can complete.

The rule. For N goroutines each sending once on one channel, buffer to N:

err_ch_142.go
// Illustrative snippet — not a complete program
errCh := make(chan error, len(items))

Then every send completes immediately into the buffer, every goroutine exits, and the receiver collects at its leisure — or does not collect at all, and the channel is garbage collected with the values still in it.

Measured buffering is not free, and it is worth knowing what it costs so the trade is explicit. A single-sender error channel with no buffer is 128 B and 2 allocations per use; buffered to one it is 144 B and 3. So the price of leak-freedom is 16 bytes and one allocation — you are not choosing between fast and safe, you are buying safety for an allocation.
The leak-prevention rule

if a goroutine might send after the receiver has stopped listening, the channel needs at least as many slots as there are senders. One sender, make(chan error, 1). N senders, make(chan error, N). The buffer solves goroutine cleanup — it does not cancel the work. To stop the work, pass ctx into the function (Chapter 13).

14.2.3 Collecting Errors from Multiple Goroutines

With every goroutine sending exactly once, the receiver knows exactly how many receives to do. That count is the termination condition — no close, no WaitGroup, no closer goroutine.

First error wins:

validate_all_142.go
// Illustrative snippet — not a complete program
func validateAll(items []Item) error {
    errCh := make(chan error, len(items))

    for _, item := range items {
        go func() { errCh <- validate(item) }()
    }

    var firstErr error
    for range items {
        if err := <-errCh; err != nil && firstErr == nil {
            firstErr = err
        }
    }
    return firstErr
}

The loop runs the full len(items) iterations even after finding an error. That is deliberate: it is what guarantees every goroutine’s send is consumed and every goroutine exits.

Collect all:

validate_form_142.go
// Illustrative snippet — not a complete program
func validateForm(fields []Field) error {
    errCh := make(chan error, len(fields))

    for _, f := range fields {
        go func() { errCh <- validate(f) }()
    }

    var errs []error
    for range fields {
        if err := <-errCh; err != nil {
            errs = append(errs, err)
        }
    }
    return errors.Join(errs...) // nil when errs is empty
}

errors.Join (Go 1.20) combines the errors into one whose Error() joins the messages with newlines, and returns nil for an empty or all-nil slice — exactly the behaviour you want, with no length check.

Criteria
Use when
Returns
Examples
Rule of thumb

if the caller’s next action is “abort everything”, take the first error. If it is “show me everything that went wrong”, collect them all.

14.2.4 The WaitGroup and Close Pattern

The counter loop requires every goroutine to send, including on success. Sometimes you want silence on success — long-running workers that report problems as they occur, where there is no fixed number of sends to count. Then the receiver needs a different termination signal, and that signal is close.

process_all_142.go
// Illustrative snippet — not a complete program
func processAll(items []Item) error {
    errCh := make(chan error, len(items))
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Go(func() {
            if err := process(item); err != nil {
                errCh <- err // only on failure
            }
        })
    }

    go func() {
        wg.Wait()
        close(errCh) // after every possible send
    }()

    var errs []error
    for err := range errCh {
        errs = append(errs, err)
    }
    return errors.Join(errs...)
}

Why the closer runs in its own goroutine. The main goroutine has two jobs that cannot be sequenced: draining with for range (which ends only at close) and knowing when the senders are finished (wg.Wait). Putting wg.Wait first and then draining works with a full-size buffer, but it collects nothing until the last goroutine finishes, and it deadlocks if the buffer is smaller than the number of failures. Draining first never reaches wg.Wait at all. A third goroutine running wg.Wait concurrently with the drain resolves it: errors are handled as they arrive, and close fires exactly once every sender is done.

Never close from the receiving side

and never from one of several senders. The closer goroutine exists so that close happens after the last send and nowhere else. A send on a closed channel is a panic, not an error.

Not every error channel needs closing. With the counter loop of §14.2.3 the receiver already knows how many values are coming, and an unclosed channel with no references is simply collected. Close when you need for range semantics, and not otherwise.

14.2.5 Integrating with Context Cancellation

select composes an error channel with cancellation exactly as §4.2 composes anything else:

charge_order_142.go
// Illustrative snippet — not a complete program
func chargeOrder(ctx context.Context, order Order) error {
    errCh := make(chan error, 1)

    go func() { errCh <- processPayment(ctx, order) }()

    select {
    case err := <-errCh:
        return err
    case <-ctx.Done():
        return ctx.Err()
    }
}

The buffer of one is what makes the ctx.Done() branch safe. Take that away and every cancellation leaks the payment goroutine.

For a batch, cancel on the first error so the rest stop early:

fetch_all_142.go
// Illustrative snippet — not a complete program
func fetchAll(ctx context.Context, urls []string) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    errCh := make(chan error, len(urls))
    for _, u := range urls {
        go func() { errCh <- fetch(ctx, u) }()
    }

    var firstErr error
    for range urls {
        if err := <-errCh; err != nil && firstErr == nil {
            firstErr = err
            cancel() // the rest see ctx.Done() and return
        }
    }
    return firstErr
}

The caller still performs all len(urls) receives, but after cancel() the remaining goroutines return almost immediately with ctx.Err(). The function returns the real error, because firstErr was already set before any context error could arrive. cancel() does not kill anything — it asks, and fetch has to be listening. That is §13.4's cooperative cancellation, unchanged.

This is the entire pattern that errgroup.WithContext packages into one line (§14.4.3).

14.2.6 Where Error Channels Shine

errgroup is better than this for a bounded batch. What it cannot do is run forever. Long-lived workers that report errors continuously as they process a stream are the case where an error channel is the right primitive and nothing else is close:

run_workers_142.go
// Illustrative snippet — not a complete program
func runWorkers(ctx context.Context, jobs <-chan Job) <-chan error {
    errCh := make(chan error, 10) // absorbs bursts
    var wg sync.WaitGroup

    for range 5 {
        wg.Go(func() {
            for {
                select {
                case <-ctx.Done():
                    return
                case job, ok := <-jobs:
                    if !ok {
                        return
                    }
                    err := job.Process()
                    if err == nil {
                        continue
                    }
                    wrapped := fmt.Errorf("job %s: %w", job.ID, err)
                    select {
                    case errCh <- wrapped:
                    case <-ctx.Done():
                        return
                    }
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(errCh)
    }()

    return errCh
}
Why the inner select

the buffer here is a burst absorber, not a guarantee — ten slots for an unbounded number of sends. If it fills while the supervisor is busy and the context is then cancelled, a bare errCh <- err would block forever and the worker would be deaf to the cancellation it is supposed to obey. The inner select gives the send an escape hatch. Any time a buffer is a heuristic rather than a bound, the send needs one.

There is no “result” here to return, and errgroup would return on the first error and stop the pool. The error channel is the only one of the three that streams.

14.2.7 Common Mistakes

Unbuffered channel with an early return
Problem

Sender parks forever; one leak per timeout

Fix

Buffer to the number of senders

Buffer smaller than the sender count
Problem

N − buffer − receives goroutines park

Fix

make(chan error, N)

Sending only on failure, with a counter loop
Problem

Receiver blocks forever on success

Fix

Always send; nil is a value

Closing before the last send
Problem

panic: send on closed channel

Fix

Close in a goroutine after wg.Wait()

Closing from the receiver
Problem

Same panic, harder to find

Fix

Only the sending side closes

Bare send into a heuristic buffer
Problem

Worker ignores cancellation when the buffer fills

Fix

Wrap the send in a select with ctx.Done()

Summary: Error Channels

An error channel is a chan error and the discipline that goes with it. The discipline is one rule stated twice: every goroutine sends exactly once on every path, and the buffer is large enough that no send can block.

Get the buffer wrong and you get one of two failures. Too small with a receiver that can leave early leaks a goroutine per abandoned send. Too small with a WaitGroup deadlocks, because the send blocks before Done can run while the main goroutine is inside Wait. Both disappear when the buffer matches the sender count, and the cost of that is 16 bytes and one allocation.

Two receive loops go with it. Count receives when every goroutine always sends — the count is the termination signal and nothing needs closing. Range over the channel when goroutines are silent on success, and then a separate goroutine must run wg.Wait and close concurrently with the drain, because the main goroutine cannot do both.

Where this pattern earns its place over errgroup is the unbounded case: long-lived workers reporting errors as they go. And there the buffer is a heuristic rather than a bound, so every send needs a select on ctx.Done() to stay cancellable when it fills.

Self-Check Questions: Error Channels

Why does this deadlock, and what is the smallest fix?

err_ch_142_2.go
// Illustrative snippet — not a complete program
var wg sync.WaitGroup
errCh := make(chan error)

for range 5 {
    wg.Go(func() { errCh <- errors.New("failed") })
}

wg.Wait()
err := <-errCh
Five senders, an unbuffered channel, and a Wait before the receive.

The channel is unbuffered, so the first send blocks waiting for a receiver. The only receiver is on the line after wg.Wait(), and wg.Wait() cannot return until all five goroutines finish — which they cannot do, because they are all blocked on the send.

Main waits for the workers; the workers wait for main. Neither moves, and the runtime reports “all goroutines are asleep — deadlock”.

The smallest fix is make(chan error, 5). Every send then completes into the buffer, every goroutine returns, wg.Wait() unblocks, and the receive finds a value waiting. Note that the code still only reads one of the five errors — that is a separate design question, not a deadlock.

Ten goroutines send on a channel buffered to one. The receiver takes a single error and returns. How many goroutines leak?

Eight, not nine.

Measured nine goroutines are parked on the send before any receive happens — one send went into the single buffer slot and the other nine are waiting. The receive then takes the buffered value, and the runtime immediately hands one blocked sender’s value into the freed slot, unblocking that sender. So the count after the receive is eight.

The general form is N − buffer − receives. It is worth doing the arithmetic rather than assuming, because the off-by-one is exactly the kind of thing that makes a leak look like it has a different cause than it does.

The fix is the same either way: make(chan error, 10).

In the WaitGroup-and-close pattern, why does wg.Wait() run in its own goroutine?

Because the main goroutine has two jobs that cannot be put in either order.

Draining with for err := range errCh runs until the channel closes. Knowing that all senders have finished requires wg.Wait(). Put wg.Wait() first and nothing is collected until the final goroutine finishes — and if the buffer is smaller than the number of failures, the senders block, Done never runs, and it deadlocks. Put the drain first and wg.Wait() is never reached, because for range is still waiting for the close that comes after it.

A third goroutine runs wg.Wait() concurrently with the drain. Errors are handled as they arrive, and close fires the moment the last sender is done.

Note that with a buffer sized to the item count, waiting first and then draining is correct — it just is not streaming. It is only wrong when the buffer can fill.

Can you return on the first of a hundred errors without leaking, using error channels alone?

Not immediately, no. The tension is exact: returning immediately means the receiver stops before the other goroutines have sent, and not leaking means every goroutine must be able to send and exit.

With a buffer of one hundred, they can all send and exit whether or not you receive — so nothing leaks even on an early return. What you give up is knowing when they finished; the goroutines are still running while your caller has moved on.

If you need both — the first error promptly, and a guarantee that nothing from this batch is still running when you return — drain all hundred receives after calling cancel(). The remaining goroutines return ctx.Err() almost at once, so the wait is short.

That is precisely what errgroup does for you (§14.4), and it is why g.Wait blocks until every goroutine has returned rather than returning on the first error.

Key Takeaways

  • One goroutine, one send, on every path — nil is a result, and a conditional send with a counter loop blocks the receiver forever
  • Buffer to the sender count; an undersized buffer leaks with an early receiver and deadlocks with a WaitGroup
  • The leak arithmetic is N − buffer − receives, and it is worth doing rather than estimating
  • Buffering costs 16 bytes and one allocation — the trade is explicit, not free
  • Counter loop when every goroutine sends: the count terminates it and nothing needs closing
  • Range loop when goroutines are silent on success: a separate goroutine runs wg.Wait then close, concurrently with the drain
  • Only the sending side closes, and only after the last send
  • When the buffer is a burst absorber rather than a bound, guard the send with select on ctx.Done()
Section 14.2 — in one line

An error channel is a channel plus one rule — every goroutine sends exactly once and no send can ever block — and both of its failure modes are that rule being broken by an undersized buffer.

14.3 Result Structs

A chan error answers one question: did it work? The moment a goroutine produces something the caller wants, that channel is the wrong shape, and the obvious repair — a second channel for the values — is worse than the disease.

14.3.1 The Two-Channel Problem

body_ch_143_x_1.go
// Illustrative snippet — not a complete program
// ✗ BAD: separate channels for values and errors
bodyCh := make(chan []byte, len(urls))
errCh := make(chan error, len(urls))

for _, url := range urls {
    go func() {
        body, err := fetch(url)
        if err != nil {
            errCh <- err
            return
        }
        bodyCh <- body
    }()
}

How many receives on each? You cannot know before running it — the split between successes and failures is the thing you are trying to discover. A select in a counted loop solves the arithmetic, and three problems survive it: which URL produced this body, which URL produced that error, and how to pair a value with the failure of the same operation. Splitting related facts across two channels discards the relationship between them.

Measured it is also the slower option. Four goroutines fetching through one Result channel cost 432 B and 10 allocations; the same work through two channels and a select costs 512 B and 12. One channel is cheaper and keeps the correlation — the design argument and the cost argument point the same way, which is not always true and is worth noticing when it is.
fetch_result_143.go
// Illustrative snippet — not a complete program
type FetchResult struct {
    URL  string // identity: safe to read whatever Err says
    Body []byte // value: meaningless when Err is non-nil
    Err  error
}

One channel. One loop. Every value self-describing.

14.3.2 The Basic Pattern

fetch_all_143.go
// Illustrative snippet — not a complete program
func fetchAll(urls []string) []FetchResult {
    ch := make(chan FetchResult, len(urls))

    for _, url := range urls {
        go func() {
            body, err := fetch(url)
            ch <- FetchResult{URL: url, Body: body, Err: err}
        }()
    }

    results := make([]FetchResult, 0, len(urls))
    for range urls {
        results = append(results, <-ch)
    }
    return results
}

The rules are §14.2's rules with a richer payload: always send, buffer to the sender count, count the receives.

Check Err before Value. This is the whole contract, and it is the same discipline as sequential Go — you do not use result before checking err. It matters more here because the compiler will not help: an unused struct field is not an error, so reading r.Body when r.Err != nil compiles cleanly and silently produces garbage.

THE RESULT CONTRACT

The contract a result struct carries. When the error field is nil the value is valid and may be used. When the error field is non-nil the value is undefined: it may be the zero value, half-written, or stale from a retry. The identity field is readable in either case, which is what it exists for. The receive order follows: take the result, check the error field first, and only then use the value.

Designing the struct. Three kinds of field, and the identity one is what fan-out is for: with ten goroutines finishing in an order nobody controls, a result without an identity cannot tell you which input failed.

query_result_143.go
// Illustrative snippet — not a complete program
type QueryResult struct {
    Query   string        // identity
    Rows    []Row         // value
    Elapsed time.Duration // value
    Err     error
}
Naming

call the field Err, not Error. Go uses err for variables and Err for exported fields, and Error collides with the method name if the type ever implements error. For the data fields prefer domain names — Body, Rows, Elapsed — over Data or Value. A result struct is a data transfer object: no mutexes, no loggers, no retry counters. Those belong to the goroutine that produces the result, not to the value it sends back.

14.3.3 Generic Result Types

When the struct is genuinely just a value and an error, generics remove the repetition:

result_143.go
// Illustrative snippet — not a complete program
type Result[T any] struct {
    Value T
    Err   error
}

A reusable helper follows, with one type parameter for the input and one for the output:

run_all_143.go
// Illustrative snippet — not a complete program
// RunAll runs fn for every item concurrently and collects the results
// in completion order. Use an indexed variant when order matters.
func RunAll[T, R any](items []T, fn func(T) (R, error)) []Result[R] {
    ch := make(chan Result[R], len(items))

    for _, item := range items {
        go func() {
            val, err := fn(item)
            ch <- Result[R]{Value: val, Err: err}
        }()
    }

    out := make([]Result[R], 0, len(items))
    for range items {
        out = append(out, <-ch)
    }
    return out
}

Each call needs its own variable, since the element types differ:

users_143.go
// Illustrative snippet — not a complete program
users := RunAll(userIDs, lookupUser)      // []Result[User]
receipts := RunAll(orders, validateOrder) // []Result[Receipt]

Use the generic form for library code and for the plain value-or-error case. Use a concrete struct the moment you need an identity field, a second value field, or a domain-meaningful type name — which in application code is most of the time. Start concrete and extract the generic version when the pattern actually repeats.

14.3.4 Collecting Results, Ordered and Not

Unordered collection is the loop you have already seen: receive len(items) times and append. Results arrive in completion order.

When results[i] must correspond to input[i], the simplest correct answer needs no channel at all:

fetch_all_143_2.go
// Illustrative snippet — not a complete program
func fetchAllOrdered(ctx context.Context,
    urls []string) ([]FetchResult, error) {
    results := make([]FetchResult, len(urls))
    var wg sync.WaitGroup

    for i, url := range urls {
        wg.Go(func() {
            body, err := httpGet(ctx, url)
            results[i] = FetchResult{URL: url, Body: body, Err: err}
        })
    }
    wg.Wait()

    var errs []error
    for _, r := range results {
        if r.Err != nil {
            errs = append(errs, fmt.Errorf("%s: %w", r.URL, r.Err))
        }
    }
    return results, errors.Join(errs...)
}

Two facts make this race-free, and both are needed. Each goroutine writes to a distinct index, so no two writes touch the same memory. And wg.Wait() establishes the happens-before edge (§8.4) between every write and the caller’s reads. Neither alone is sufficient: distinct indices without the barrier is still a race with the reader.

Criteria
Order
Streaming
Mechanism
Use when
Default to the slice.

No channel, no buffer decision, correct order for free. Reach for a channel when the consumer genuinely benefits from acting on results as they arrive — a progress bar, a streaming response, or work triggered by the first success.

First success wins is the useful variant of the unordered loop:

fetch_from_143.go
// Illustrative snippet — not a complete program
func fetchFromMirrors(ctx context.Context,
    mirrors []string) ([]byte, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // stop the losers once we have an answer

    ch := make(chan FetchResult, len(mirrors))
    for _, m := range mirrors {
        go func() {
            body, err := httpGet(ctx, m)
            ch <- FetchResult{URL: m, Body: body, Err: err}
        }()
    }

    var lastErr error
    for range mirrors {
        r := <-ch
        if r.Err == nil {
            return r.Body, nil // defer cancel() releases the rest
        }
        lastErr = r.Err
    }
    return nil, fmt.Errorf("all %d mirrors failed: %w",
        len(mirrors), lastErr)
}

Returning early leaves goroutines running, and that is fine here: the buffer holds len(mirrors) values and each goroutine sends once, so no send can block. They finish, deposit, and exit; the channel is collected with values still in it.

14.3.5 Partial Results

Result structs are the only one of the three mechanisms that shows the caller everything — the successes, the failures, and which is which:

health_result_143.go
// Illustrative snippet — not a complete program
type HealthResult struct {
    Service string
    Latency time.Duration
    Err     error
}

func checkServices(ctx context.Context,
    services []string) ServiceReport {
    ch := make(chan HealthResult, len(services))
    for _, svc := range services {
        go func() {
            latency, err := ping(ctx, svc)
            ch <- HealthResult{Service: svc, Latency: latency, Err: err}
        }()
    }

    var report ServiceReport
    for range services {
        if r := <-ch; r.Err != nil {
            report.Failed = append(report.Failed, r)
        } else {
            report.Healthy = append(report.Healthy, r)
        }
    }
    return report
}

The caller then decides the policy, which is the whole point — §14.7 is about making that decision well. When aggregating errors, include the identity or the report is useless:

errs_143.go
// Illustrative snippet — not a complete program
errs = append(errs, fmt.Errorf("%s: %w", r.Service, r.Err))
// cache: connection refused
// search: timeout after 5s

Partial results are the wrong answer when all results are required, when the results are interdependent, or when partial data is misleading rather than incomplete — an average over seven of ten values is not approximately right, it is wrong.

14.3.6 Cancel on First Error

When every goroutine must succeed, stop the rest as soon as one fails:

fetch_all_143_3.go
// Illustrative snippet — not a complete program
func fetchAllOrNothing(ctx context.Context,
    urls []string) ([]FetchResult, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    ch := make(chan FetchResult, len(urls))
    for _, url := range urls {
        go func() {
            body, err := httpGet(ctx, url)
            ch <- FetchResult{URL: url, Body: body, Err: err}
        }()
    }

    results := make([]FetchResult, 0, len(urls))
    for i := range urls {
        r := <-ch
        if r.Err != nil {
            cancel() // the rest return ctx.Err() shortly

            // Drain the outstanding sends before returning, so that
            // when this function returns, none of its goroutines are
            // still running. Nothing would block if we skipped this
            // -- the buffer holds every send -- but the goroutines
            // would outlive the call and be cleaned up whenever the
            // scheduler and GC got to them.
            for range len(urls) - i - 1 {
                <-ch
            }
            return nil, fmt.Errorf("fetch %s: %w", r.URL, r.Err)
        }
        results = append(results, r)
    }
    return results, nil
}
The drain is about determinism, not leaks

this is worth being precise about, because the usual explanation is wrong. With the buffer at len(urls) and one send per goroutine, no send can block — measured: deleting the drain loop entirely and running the function two hundred times leaked exactly zero goroutines. What the drain buys is a guarantee about when: after it, every goroutine from this call has returned. Without it they finish on their own schedule, and a test that counts goroutines afterwards becomes flaky. Keep the drain for the guarantee; do not keep it because you were told it prevents a leak.

§14.4.3 replaces all of this — the derived context, the cancel, the drain — with errgroup.WithContext, which provides the same guarantee by construction.

14.3.7 Common Mistakes

Using Value before checking Err
Problem

Zero values treated as real data; nil dereference

Fix

Check Err first, always

No identity field
Problem

You know something failed, not what

Fix

Add URL, ID, Index, Service

Value set alongside a non-nil Err
Problem

Ambiguous: is this partial data or nothing?

Fix

Zero the value on the error path, or document it

Goroutine exits without sending
Problem

Receiver blocks forever on the missing receive

Fix

defer/recover that sends (§14.6.3)

append to a shared slice
Problem

Data race on the slice header

Fix

Channel, or indexed writes plus wg.Wait()

Unbuffered result channel
Problem

Sender parks after an early return

Fix

Buffer to the sender count

Summary: Result Structs

A result struct is one channel carrying three things: an identity, a value, and an error. That bundling is what two channels cannot do — split a value from its error and you lose the fact that they describe the same operation, along with any way to say which input produced either. It is also cheaper, at 432 B against 512 B for four goroutines.

The contract is one line: when Err is nil the value is valid, when Err is non-nil the value is undefined, and the identity is readable either way. The compiler enforces none of this, which is why the discipline has to be explicit.

Order costs nothing if you want it. Writing to distinct indices of a pre-allocated slice and waiting on a WaitGroup is race-free — distinct writes plus the happens-before edge from Wait — and needs no channel at all. Use a channel when the consumer benefits from results as they arrive.

Returning early from a fully buffered result channel does not leak, because every send has a slot waiting. Draining anyway buys determinism about when the goroutines finish, which is a real guarantee and a different one from leak-freedom.

Self-Check Questions: Result Structs

What does a result struct do that a chan error cannot?

Carry the value, and carry the correlation.

A chan error reports whether an operation failed. When the operation also produces something, you need both halves, and the natural repair — one channel for values, one for errors — loses the pairing. You cannot tell which URL a body came from, which URL an error belongs to, or that a particular body and a particular error describe the same call. You also cannot predict how many receives each channel needs, because the success/failure split is what you are trying to discover.

A struct keeps the three facts that belong together in one value, sent once, received once.

What is wrong here?

r_143.go
// Illustrative snippet — not a complete program
r := <-results
fmt.Printf("fetched %d bytes from %s\n", len(r.Body), r.URL)
if r.Err != nil {
    log.Printf("error: %v", r.Err)
}
The print runs before the error check.

r.Body is used before r.Err is checked.

On a failed fetch Body is nil. len(nil) is 0 rather than a panic, so this does not crash — it prints fetched 0 bytes from https://… for a request that never happened, and only afterwards logs the error. Anyone reading the output sees a successful fetch of an empty document. Had the value field been a pointer, the same code would have panicked.

Check the error first and continue, then use the value. Reading r.URL before the check is fine — identity fields are populated on both paths, which is what makes them useful for the error message.

Ten goroutines send a Result on a buffered channel. One panics instead of sending. What happens?

The collection loop blocks forever on its tenth receive.

Nine results arrive. The tenth never does, because the panicking goroutine unwound its stack without reaching the send. If the panic is unrecovered the process dies anyway and the block is academic — but a recover that does not send leaves the receiver waiting on a value that will never exist.

The fix is to guarantee the send on every exit path by putting it inside the deferred function, after the recover check, so a panic produces a result carrying the error rather than no result at all. §14.6.3 has the wrapper.

Does returning early from fetchFromMirrors leak the losing goroutines?

No, and the reason is the buffer.

The channel holds len(mirrors) values and each goroutine sends exactly once, so every send has a slot regardless of whether anyone is receiving. The losers finish, deposit their results into the buffer, and exit. The channel becomes unreachable and is collected with the values still in it.

What the early return does mean is that those goroutines are still running when the caller moves on. defer cancel() shortens that window by telling them to stop, but it does not close it — cancellation is cooperative. If you need a hard guarantee that nothing from the call is still running when it returns, drain the remaining receives, which is exactly the §14.3.6 pattern and exactly what g.Wait gives you for free.

Key Takeaways

  • One channel carrying identity, value and error beats two channels on correctness and on cost — 432 B against 512 B for four goroutines
  • The contract: Err == nil means the value is valid, Err != nil means it is undefined, identity is readable either way
  • The compiler cannot enforce it — an unused struct field is legal, so checking Err first is a discipline you keep yourself
  • Name the field Err, give data fields domain names, and keep infrastructure out of the struct
  • Distinct-index writes plus wg.Wait() gives ordered collection with no channel and no mutex; both halves are required
  • Returning early from a fully buffered channel does not leak — every send has a slot
  • Draining after cancel() buys determinism about when goroutines finish, not leak-freedom
Section 14.3 — in one line

Bundle the value with its error and its name, because they describe one operation — and once every send has a slot, the only thing left to decide is whether you want the goroutines finished before you return.

14.4 errgroup

Sections 14.2 and 14.3 built the return path from primitives, and that was the point — you now know what the buffer is for and what the drain buys. This section is the package that does it for you, and the reason to reach for it is not only that it is shorter.

14.4.1 The Boilerplate Problem

Validating a set of configuration files by hand needs a WaitGroup, a channel with a buffer decision, a closer goroutine and a drain loop:

validate_configs_144_x_1.go
// Illustrative snippet — not a complete program
// ✗ Manual, and subtly wrong
func validateConfigs(ctx context.Context, paths []string) error {
    var wg sync.WaitGroup
    errCh := make(chan error, len(paths))

    for _, path := range paths {
        wg.Go(func() { errCh <- validateConfig(ctx, path) })
    }

    go func() {
        wg.Wait()
        close(errCh)
    }()

    for err := range errCh {
        if err != nil {
            return err // ✗ returns while goroutines are still running
        }
    }
    return nil
}

The bug is the early return: the function exits with workers still in flight and the closer goroutine orphaned. Fixing it means draining every send first, which adds more ceremony to the thing that already had too much.

validate_configs_144_2.go
// Illustrative snippet — not a complete program
// ✓ errgroup
func validateConfigs(ctx context.Context, paths []string) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, path := range paths {
        g.Go(func() error { return validateConfig(ctx, path) })
    }

    return g.Wait()
}
Measured it is also the cheaper option, which is not the usual direction for a convenience wrapper. Eight goroutines through the manual version printed above — WaitGroup, buffered channel, closer goroutine, range loop — cost 600 B and 20 allocations; the same eight through errgroup.Group cost 256 B and 9. At a hundred it is 5,944 B / 204 against 2,464 B / 101, and errgroup was faster in every run as well (21.4 µs against 41.8 µs). The margin depends on which manual shape you compare against — the leaner counter loop from §14.2.3, with no WaitGroup at all, is 368 B / 10 at eight and 3,504 B / 102 at a hundred. That one is closer, and still more than errgroup in both bytes and allocations. Whichever shape you would have written, the package allocates less than it.
On taking the dependency.

errgroup is not in the standard library, and some teams have a rule about that. What is true: it is maintained by the Go team, it has no transitive dependencies, and go get golang.org/x/sync is the whole installation. What is not true is that it is covered by the Go 1 compatibility promise — the golang.org/x/… repositories are separately versioned modules explicitly outside it, and the API does still grow: SetLimit and TryGo were added in 2022. In practice the changes have been additive, and §14.4.8 is short enough that a team that genuinely cannot take the dependency can build the equivalent from §14.2's primitives.

14.4.2 Basic Usage

The zero value works and needs no constructor:

validate_all_144.go
// Illustrative snippet — not a complete program
func validateAll(items []Item) error {
    var g errgroup.Group

    for _, item := range items {
        g.Go(func() error { return validate(item) })
    }

    return g.Wait()
}

g.Wait() blocks until every goroutine launched by g.Go has returned, then gives you the first non-nil error — first in the temporal sense, whichever reached the group’s internal state first. Errors after that are discarded. That is a deliberate trade of completeness for simplicity, and §14.4.7 shows what to do when you need all of them.

The guarantee that Wait waits for all goroutines is the important one: there is no way to leak a goroutine through this API. In §14.2 you got that by draining N receives; here it is structural.

Loop variables

since Go 1.22 each iteration gets a fresh variable, so the url := url line that older errgroup examples all carry is unnecessary and does not appear anywhere in this chapter. If you maintain code targeting Go 1.21 or earlier, every closure passed to g.Go that reads a loop variable needs the shadow.

14.4.3 WithContext: Cancel on First Error

errgroup.WithContext returns a group and a derived context. When the first goroutine returns a non-nil error, that context is cancelled:

fetch_all_144.go
// Illustrative snippet — not a complete program
func fetchAll(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, url := range urls {
        g.Go(func() error { return httpGet(ctx, url) })
    }

    return g.Wait()
}

Shadowing the outer ctx is intentional. Give the derived context a different name and you leave a working ctx in scope that silently bypasses the cancellation:

g_144_x_1.go
// Illustrative snippet — not a complete program
// ✗ BAD: gCtx exists, but the goroutine uses the parent
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error { return httpGet(ctx, url) })

Shadow it unless you need the parent afterwards — cleanup after g.Wait() is the usual reason, since the derived context is always cancelled by then.

What g.Wait actually returns. It returns the first non-nil error any goroutine returned, and it does no filtering whatsoever. When a real failure landed first, that is your root cause. When the parent context was cancelled instead, every goroutine returns ctx.Err() and that is what you get:

Terminal
g.Wait() → "connection refused" a goroutine failed
g.Wait() → context.Canceled the parent was cancelled
g.Wait() → context.DeadlineExceeded the parent's deadline expired
Measured all three, on go1.26.1 with x/sync v0.22.0. The common claim that g.Wait “never returns context.Canceled” is true only in the first case, and code that branches on the error has to handle the other two.

Cancellation is still cooperative. errgroup cancels a context; it cannot stop a goroutine. A g.Go closure that never checks ctx and calls a function that does not take one will run to completion regardless.

ctx is dead after Wait

the derived context is cancelled in two situations — immediately when the first goroutine returns an error, and unconditionally when Wait returns, so the context’s resources are released even on the all-succeeded path. Either way, do not use it after g.Wait().

14.4.4 Concurrency Limiting

g.SetLimit(n) caps the number of goroutines active in the group. Past the limit, g.Go blocks the caller until a slot frees:

g_144_2.go
// Illustrative snippet — not a complete program
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)

for _, url := range urls {
    g.Go(func() error { return httpGet(ctx, url) }) // blocks at 10
}
return g.Wait()

That is the difference from the semaphore pattern it replaces. A semaphore inside the closure launches every goroutine immediately and has them queue:

sem_144.go
// Illustrative snippet — not a complete program
// Before SetLimit: all N goroutines exist, 10 of them work
sem := make(chan struct{}, 10)
g.Go(func() error {
    sem <- struct{}{}
    defer func() { <-sem }()
    return httpGet(ctx, url)
})
Measured the cost of the goroutines that exist only to wait is real. Ten thousand goroutines parked on a semaphore hold 2,081 bytes of stack and 606 bytes of heap each — 2.7 KB apiece, 25.6 MiB in total on the reference machine, stable across runs. With SetLimit(10) at most ten exist at a time, so the same work costs about 27 KiB. The commonly quoted “2 KB per goroutine” is the stack half only; the g struct and its bookkeeping are the rest.
WHERE THE BLOCKING HAPPENS

Two ways to bound concurrency, compared by where they block. With a semaphore inside the closure, the caller loop creates all ten thousand goroutines at once; ten run and 9,990 park on the semaphore, costing about 25.6 mebibytes of parked goroutines. With SetLimit of ten, the caller loop itself blocks inside g.Go once the limit is reached, so at most ten goroutines exist at any moment and all of them are running, costing about 27 kibibytes. g.Go blocks the caller, and the goroutine is not created until there is room for it to run.

Rules worth knowing:

TryGo is Go without the blocking — it returns false rather than waiting, which is what load shedding needs:

snippet_144.go
// Illustrative snippet — not a complete program
for req := range requests {
    if !g.TryGo(func() error { return processRequest(ctx, req) }) {
        req.Respond(http.StatusServiceUnavailable)
    }
}

Without a limit set, TryGo always succeeds, because there is no limit to reach.

Choosing the limit. Start at runtime.NumCPU() for CPU-bound work and roughly double that for I/O-bound, then measure. The real bound is usually a downstream resource: a connection pool size, a rate limit you were given, a file-descriptor ceiling, or total memory divided by per-item footprint.

14.4.5 The Return Value Decision

What the closure returns is the entire error strategy. There are three answers.

return err — fail fast. The first failure cancels the derived context and Wait reports it. Use when any single failure invalidates the batch.

return nil — collect all. No error reaches the group, nothing is cancelled, every goroutine runs to completion, and Wait returns nil. The failures live wherever you put them, usually a results slice. Use when partial results have value.

Conditional — selective. Only fatal errors propagate:

g_144_3.go
// Illustrative snippet — not a complete program
g.Go(func() error {
    val, err := process(ctx, order)
    results[i] = Result[Value]{Value: val, Err: err}

    if isInfrastructureError(err) {
        return err // database down: stop everything
    }
    return nil // one bad order: collect it and continue
})
g.Go returns
err
nil
Conditional

14.4.6 Composing with Result Structs

errgroup returns errors, never values. Combining it with §14.3's indexed slice is the most common production shape in this chapter:

service_144.go
// Illustrative snippet — not a complete program
type Service struct {
    Name     string
    Critical bool
}

func (s Service) Ping(ctx context.Context) error { /* ... */ }

type ServiceStatus struct {
    Name    string
    Healthy bool
    Latency time.Duration
    Err     error
}

func checkHealth(ctx context.Context,
    services []Service) ([]ServiceStatus, error) {
    results := make([]ServiceStatus, len(services))
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(5)

    for i, svc := range services {
        g.Go(func() error {
            start := time.Now()
            err := svc.Ping(ctx)
            results[i] = ServiceStatus{
                Name:    svc.Name,
                Healthy: err == nil,
                Latency: time.Since(start),
                Err:     err,
            }
            if svc.Critical && err != nil {
                return fmt.Errorf("critical service %s: %w",
                    svc.Name, err)
            }
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return results, err // partial results are still useful
    }
    return results, nil
}

The slice writes are safe for the same two reasons as §14.3.4: distinct indices, and the happens-before edge that g.Wait() provides. WithContext earns its place even though most closures return nil — if the parent is cancelled because the server is shutting down, every ping stops promptly.

14.4.7 Collecting All Errors

Wait gives you one error. Two ways to get the rest.

Store them in the results slice, which costs nothing extra if you already have one:

errs_144.go
// Illustrative snippet — not a complete program
var errs []error
for _, r := range results {
    if r.Err != nil {
        errs = append(errs, fmt.Errorf("%s: %w", r.Name, r.Err))
    }
}
return errors.Join(errs...)

Use a mutex when the goroutines are side-effecting and have no natural index:

all_errs_144.go
// Illustrative snippet — not a complete program
var (
    mu      sync.Mutex
    allErrs []error
)

for _, region := range regions {
    g.Go(func() error {
        if err := deploy(ctx, region, artifact); err != nil {
            mu.Lock()
            allErrs = append(allErrs, fmt.Errorf("%s: %w", region, err))
            mu.Unlock()
        }
        return nil // never cancel a sibling region
    })
}
g.Wait()

14.4.8 How errgroup Works

The package is about a hundred lines. The parts that matter:

group_144.go
// Illustrative snippet — not a complete program
type Group struct {
    cancel  func(error)     // from context.WithCancelCause
    wg      sync.WaitGroup
    sem     chan token      // nil unless SetLimit was called
    errOnce sync.Once
    err     error
}

func WithContext(ctx context.Context) (*Group, context.Context) {
    ctx, cancel := context.WithCancelCause(ctx)
    return &Group{cancel: cancel}, ctx
}

func (g *Group) Go(f func() error) {
    if g.sem != nil {
        g.sem <- token{} // blocks the caller at the limit
    }
    g.wg.Add(1)
    go func() {
        defer g.done()
        if err := f(); err != nil {
            g.errOnce.Do(func() {
                g.err = err
                if g.cancel != nil {
                    g.cancel(g.err)
                }
            })
        }
    }()
}

Everything in §14.2 and §14.3 is visible here: a WaitGroup for the lifecycle, a sync.Once for the first-error guard, a buffered channel as a semaphore. Wait calls g.wg.Wait() and then g.cancel(g.err) unconditionally, which is why the derived context is always done afterwards.

The detail worth carrying away is WithCancelCause. The cancel function takes an error, and errgroup passes it the failure that triggered the cancellation. So inside a sibling goroutine that has just noticed ctx.Done():

snippet_144_2.go
// Illustrative snippet — not a complete program
select {
case <-ctx.Done():
    // ctx.Err()          → context.Canceled     (says nothing)
    // context.Cause(ctx) → "db is down"         (the real reason)
    return ctx.Err()
}
Measured with a goroutine returning errors.New("db is down"), a sibling reading context.Cause(ctx) gets that exact error and errors.Is matches it; ctx.Err() gets context.Canceled. That is Chapter 13's WithCancelCause doing real work — a stage can log why the pipeline is shutting down while it shuts down, which §14.5.7 otherwise has no answer for. Measured WithContext costs 96 bytes and 2 allocations more than a plain Group at eight goroutines. That is the cancelCtx from §13.4, and it is the entire price of automatic cancellation.
Why errgroup will not recover panics.

This is deliberate, and the source says so in a comment citing four Go issues: propagating a panic to Wait would delay it arbitrarily, turn the panic’s stack into a mere value and hide it from crash-monitoring tools, and risk deadlocks that hide the panic entirely if the panicking goroutine leaves the group unable to reach Wait. A panic in g.Go crashes the program exactly as it would in any other goroutine. If your closures can panic, wrap them (§14.6.3).

14.4.9 Common Mistakes

Using the parent ctx inside g.Go
Problem

Goroutines never see the cancellation

Fix

Shadow: g, ctx := errgroup.WithContext(ctx)

Calling the work but returning nil
Problem

Errors vanish; Wait reports success

Fix

return fetch(url), not fetch(url); return nil

Logging the error and returning nil
Problem

Same, with a log line to prove it happened

Fix

Return it; log where you decide what to do

Forgetting g.Wait()
Problem

Goroutines orphaned, errors never observed

Fix

Always call it and handle the result

Reusing a group after Wait
Problem

The second batch’s error is silently dropped

Fix

A new group per batch

Changing the limit mid-flight
Problem

Panic at runtime

Fix

Set it once, before the first g.Go

A closure that can panic
Problem

Process crash — the group does not recover

Fix

defer/recover with a named return (§14.6)

append to a shared slice from g.Go
Problem

Data race on the slice header

Fix

Indexed writes, or a mutex

Nested g.Go on a limited group
Problem

Deadlock: inner calls wait for slots outers hold

Fix

A separate group for the inner level

Two of these deserve more than a row.

Reuse after Wait fails silently. The group’s sync.Once has already fired, so the second batch’s error never reaches g.err:

Terminal
first Wait: task1 failed
second Wait: task1 failed ← task2's error is dropped
Measured with var g errgroup.Group, a failing first batch and a failing second, Wait reports the first batch’s error both times. If the first batch succeeded the reuse appears to work, which is worse — the failure only shows up once something has actually gone wrong. Create a new group per batch.

Changing the limit panics, but the guard is a race. SetLimit panics when len(g.sem) != 0 — when goroutines currently hold tokens:

Terminal
errgroup: modify limit while 1 goroutines in the group
are still active
Measured two hundred attempts with a blocked task panicked every time — but that is not the interesting half. Go puts the token into the semaphore synchronously in the caller, before it starts the goroutine, so a SetLimit on the very next line panics no matter how trivial the task is. Sleep 100 µs first, so the goroutine can actually be scheduled and finish, and two hundred attempts panic zero times: the guard sees an empty channel and silently swaps the semaphore instead. SetLimit after Wait is silent for the same reason. So it is a check on a live channel length, not a contract — treat “it panicked in my test” as a warning about timing, not a guarantee you will be told.

Summary: errgroup

errgroup packages the WaitGroup, the error channel, the closer goroutine and the drain loop into Go and Wait. It is not only shorter — it allocates about 40% less than the pattern it replaces, 256 bytes against 448 at eight goroutines, and it removes an entire class of bug because Wait cannot return while a goroutine from the group is still running.

WithContext derives a context cancelled by the first error. Wait returns that error unfiltered, which means it returns context.Canceled when the parent was cancelled rather than a goroutine failing — the filtering everyone assumes is there is not.

SetLimit blocks the caller rather than the goroutine, so the goroutine is never created until there is room to run it. That is the difference between ten goroutines and ten thousand, which on this machine is 27 KiB against 25.6 MiB.

What the closure returns is the strategy: err for fail-fast, nil for collect-all, conditional for selective. And underneath it is all Chapter 12's primitives — a WaitGroup, a sync.Once, a buffered channel — plus Chapter 13's WithCancelCause, which is why context.Cause inside a group member tells you why the group is shutting down.

Self-Check Questions: errgroup

When would you choose var g errgroup.Group over errgroup.WithContext?

When you want every goroutine to run to completion regardless of what the others do.

The plain Group has no context and cancels nothing, so a failure in one closure has no effect on its siblings — that is the collect-all shape, and pairing it with return nil makes the intent explicit twice.

It also fits when the work does not accept a context at all, or when there is no parent context to derive from.

In practice WithContext is the more common choice, because most production goroutines do I/O and should stop when the request they belong to is abandoned. Even in a collect-all design it earns its place: it propagates the parent’s cancellation to every member, which the plain Group cannot do.

A goroutine in the group returns errors.New("db is down"). A sibling wakes on ctx.Done(). What can it learn?

Everything, if it asks the right question.

ctx.Err() returns context.Canceled — true but useless, since it says the context ended without saying why. context.Cause(ctx) returns the original "db is down" error, and errors.Is matches against it.

This works because errgroup.WithContext is built on context.WithCancelCause (§13.3.4), and Go passes the triggering error into the cancel function. The cause is available to every goroutine holding the derived context, while they are still running.

That matters for a pipeline stage that wants to log why it is shutting down, or to distinguish “the group failed” from “the caller gave up” before deciding whether to flush a partial batch.

Why does g.Wait() wait for every goroutine instead of returning on the first error?

So that “Wait returned” means “nothing from this group is still running”.

If it returned early, the remaining goroutines would still be executing with no way for the caller to synchronise with them. They would hold connections, file handles and memory past the point where the calling code believes the operation is over — and any test that counts goroutines afterwards would be flaky.

It is the same guarantee §14.2 obtained by draining all N receives from a buffered channel, and §14.3.6 by draining after cancel(). errgroup provides it structurally instead of by convention, which is the strongest form of it: there is no way to leak a goroutine through this API.

The cost is that a fail-fast group is only as fast as its slowest member. WithContext mitigates that by cancelling the rest, but cancellation is cooperative — a closure that ignores ctx still runs to completion.

Explain why SetLimit(10) is more memory-efficient than a semaphore for ten thousand items.

Because the blocking happens before the goroutine exists rather than inside it.

With a semaphore in the closure, g.Go creates all ten thousand goroutines immediately and they queue on sem <- struct{}{}. Ten do work; 9,990 exist only to wait.

Measured each parked goroutine holds 2,081 bytes of stack and 606 bytes of heap — about 2.7 KB — so ten thousand of them is 25.6 MiB.

SetLimit(10) puts the block in g.Go itself, in the calling goroutine. The eleventh call does not return until a slot frees, so the goroutine for the eleventh item is not created until it can run. At most ten exist at any moment: roughly 27 KiB.

Three orders of magnitude, for a workload where both versions do exactly the same amount of useful work.

Key Takeaways

  • errgroup allocates about 40% less than the hand-rolled equivalent — 256 B / 9 allocs against 448 B / 11 at eight goroutines — and is no slower
  • Wait returns the first non-nil error unfiltered, so it does return context.Canceled when the parent was cancelled
  • Shadow the derived context; a differently named one leaves a working parent in scope that bypasses cancellation
  • SetLimit blocks the caller, so goroutines are not created until they can run: 27 KiB against 25.6 MiB at ten thousand items
  • SetLimit panics if the limit changes while goroutines hold tokens, and the guard is a live length check rather than a contract
  • What the closure returns is the whole strategy: err fail-fast, nil collect-all, conditional selective
  • A group is single-use — after Wait the sync.Once has fired and the next batch’s error is silently dropped
  • It is built on WithCancelCause, so context.Cause(ctx) gives a sibling the real reason while ctx.Err() only says “canceled”
  • It does not recover panics, deliberately — recovering would delay the panic and hide its stack from crash monitoring
Section 14.4 — in one line

errgroup is the WaitGroup, the buffered channel and the sync.Once you would have written, wired correctly every time — and it costs less than the version you would have written by hand.

14.5 Pipeline Error Handling

Chapter 7 built pipelines and deferred one question to this chapter: what happens when a stage fails? The answer is different from §14.2 through §14.4 because pipeline stages are connected — one stage’s output is the next one’s input — and that changes both where an error can go and what has to happen to the stages that are not failing.

14.5.1 Why Pipelines Are Different

A parallel group is N independent goroutines reporting to one caller. Errors go straight back. A pipeline has three properties that group does not:

Errors have a direction. A failure in stage two either travels forward through stages three, four and five as data, or triggers a backward cancellation. Those are different designs, not different spellings of one design.

Cancellation flows the wrong way. When a late stage fails, the early stages have to stop producing. Nothing in the channel topology does that — channels carry data downstream, and closure only propagates downstream too.

There is data in flight. Values sit in inter-stage buffers and goroutines sit blocked on sends. All of it has to unwind or the pipeline leaks.

WHERE A PIPELINE ERROR CAN GO

A four-stage pipeline with an error occurring in the middle, and the two directions that error can travel. Forward, as data: a Result carrying the error rides the channels to the end, every item still flows, and the consumer decides what to do. Backward, as a signal: the context is cancelled and every stage stops. Which one you want is a property of the work rather than of the pipeline. A log processor should skip a bad line; a financial validator must not.

Three strategies follow: propagate errors as values, fail fast with errgroup, or classify per-error and do both.

14.5.2 Propagating Errors as Values

The first fallible stage changes its return type from <-chan T to <-chan Result[T]. That stage is the error boundary, and every stage downstream of it works in Result[T].

THE ERROR BOUNDARY

Four pipeline stages and the point where the channel type changes. readLines emits a plain channel of strings. parse is the first stage that can fail, so it emits a channel of Result of Record instead, and validate and enrich carry that same type onward. That first fallible stage is the error boundary. Upstream of it a plain channel is enough; downstream of it every value carries either its own error or its own success, and the type says so.

parse_145.go
// Illustrative snippet — not a complete program
func parse(ctx context.Context,
    lines <-chan string) <-chan Result[Record] {
    out := make(chan Result[Record])
    go func() {
        defer close(out)
        for line := range lines {
            record, err := parseLine(line)
            select {
            case <-ctx.Done():
                return
            case out <- Result[Record]{Value: record, Err: err}:
            }
        }
    }()
    return out
}

Two things: the stage always sends, success or failure, so nothing is lost; and the stage makes no policy decision about whether a parse error should stop anything. That is the consumer’s call.

The pass-through rule. A stage receiving a Result with a non-nil Err forwards it unchanged and never inspects Value:

validate_145.go
// Illustrative snippet — not a complete program
func validate(ctx context.Context,
    in <-chan Result[Record]) <-chan Result[Record] {
    out := make(chan Result[Record])
    go func() {
        defer close(out)
        for r := range in {
            if r.Err != nil {
                select { // pass through untouched
                case <-ctx.Done():
                    return
                case out <- r:
                }
                continue
            }
            err := validateRecord(r.Value)
            select {
            case <-ctx.Done():
                return
            case out <- Result[Record]{Value: r.Value, Err: err}:
            }
        }
    }()
    return out
}

A stage has three options on receiving an error, and only two of them are ever right: forward it unchanged, or wrap it with this stage’s context (fmt.Errorf("at enrichment: %w", r.Err)) and forward that. Dropping it with a bare continue is §14.1's silent failure rebuilt inside a pipeline.

Assembly is then identical to a pipeline with no errors in it, which is the point:

process_logs_145.go
// Illustrative snippet — not a complete program
func processLogs(ctx context.Context, filename string) error {
    lines, genErrCh := readLines(ctx, filename) // see §14.5.6
    parsed := parse(ctx, lines)
    validated := validate(ctx, parsed)
    enriched := enrich(ctx, validated)

    var errs []error
    var stored int
    for r := range enriched {
        if r.Err != nil {
            errs = append(errs, r.Err)
            continue
        }
        if err := store(r.Value); err != nil {
            errs = append(errs, fmt.Errorf("store: %w", err))
            continue
        }
        stored++
    }

    if err := <-genErrCh; err != nil { // the generator's own failure
        return fmt.Errorf("read %s: %w", filename, err)
    }

    slog.Info("processing complete",
        "stored", stored, "errors", len(errs))
    if len(errs) > 0 {
        return fmt.Errorf("%d records failed (first: %w)",
            len(errs), errs[0])
    }
    return nil
}
The pass-through tax

every stage after the boundary checks r.Err and forwards. With ten stages downstream, one bad record is checked ten times before anyone acts on it. That boilerplate is the price of collect-all, and it is real. A generic helper removes most of it when stages share the shape func(context.Context, In) (Out, error); write the stage out by hand when it needs to inspect the error itself, which classification (§14.5.4) always does.

14.5.3 Fail-Fast with errgroup

When partial results are useless — a migration, a transaction, a configuration load — the first error should stop everything. Make each stage a member of one group:

process_files_145.go
// Illustrative snippet — not a complete program
func processFiles(ctx context.Context, dir string) error {
    g, ctx := errgroup.WithContext(ctx)

    paths := make(chan string)
    contents := make(chan FileData)

    g.Go(func() error { // stage 1: list
        defer close(paths)
        entries, err := os.ReadDir(dir)
        if err != nil {
            return fmt.Errorf("list: %w", err)
        }
        for _, e := range entries {
            if e.IsDir() {
                continue
            }
            select {
            case <-ctx.Done():
                return ctx.Err()
            case paths <- filepath.Join(dir, e.Name()):
            }
        }
        return nil
    })

    g.Go(func() error { // stage 2: read and parse
        defer close(contents)
        for path := range paths {
            data, err := readAndParse(path)
            if err != nil {
                return fmt.Errorf("parse %s: %w",
                    filepath.Base(path), err)
            }
            select {
            case <-ctx.Done():
                return ctx.Err()
            case contents <- data:
            }
        }
        return nil
    })

    g.Go(func() error { // stage 3: consume
        for data := range contents {
            if err := writeOutput(ctx, data); err != nil {
                return fmt.Errorf("write: %w", err)
            }
        }
        return nil
    })

    return g.Wait()
}

The shutdown cascade. Two mechanisms unwind the pipeline, and neither is sufficient alone:

TWO DIRECTIONS, TWO MECHANISMS

Three pipeline stages with the middle one failing, and the two mechanisms that unwind them. Context cancellation flows upstream and channel closure flows downstream. A stage blocked on a send is freed by ctx.Done; a stage blocked on a receive is freed by the channel closing. The sequence: stage two fails, errgroup cancels the context, stage one unblocks from its send and returns so its deferred close fires, stage two’s deferred close fires in turn, stage three’s range loop ends, and g.Wait returns stage two’s error.

Every stage therefore needs three things: defer close(out) so downstream can end, a select on ctx.Done() around every send so it can be freed when downstream stops reading, and return ctx.Err() so the goroutine actually exits. The last stage has no output channel and so no defer close; if it does slow work, pass ctx into that work.

Every send needs the select

this is the single most common bug in fail-fast pipelines. A bare out <- value in a stage whose consumer has stopped reading blocks forever, the goroutine never returns, and g.Wait() never returns either. The pipeline does not crash — it hangs, which is harder to diagnose. Chapter 7 put select on ctx.Done() in every stage template for exactly this reason.

14.5.4 Classifying Errors Within Stages

Real pipelines are rarely all-or-nothing. A scraper should skip a 404 and abort on a DNS failure. An importer should skip a malformed row and abort when the database connection drops. The rule that separates them: an error is fatal if it affects every remaining item, and recoverable if it affects only this one.

severity_145.go
// Illustrative snippet — not a complete program
type Severity int

const (
    Skip  Severity = iota // log it, keep going
    Fatal                 // cancel the pipeline
)

func classifyFetchError(err error) Severity {
    var dnsErr *net.DNSError
    if errors.As(err, &dnsErr) {
        return Fatal // the whole domain is unreachable
    }

    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        switch {
        case httpErr.StatusCode == 404, httpErr.StatusCode == 429:
            return Skip
        case httpErr.StatusCode >= 500:
            return Fatal
        }
    }

    if errors.Is(err, context.DeadlineExceeded) {
        return Skip // one slow item, not a broken pipeline
    }

    return Fatal // unknown errors are fatal by default
}

Go 1.26’s errors.AsType[*net.DNSError](err) and errors.AsType[*HTTPError](err) return (value, ok) and remove the two declarations; either form is fine.

Inside a g.Go closure the classification picks the return value — which is §14.4.5's decision, applied per error rather than per goroutine:

wrapped_145.go
// Illustrative snippet — not a complete program
g.Go(func() error {
    defer close(fetched)
    for url := range urls {
        page, err := httpGet(ctx, url)
        if err != nil {
            wrapped := fmt.Errorf("fetch %s: %w", url, err)
            if classifyFetchError(err) == Fatal {
                return wrapped
            }
            select { // recoverable: send it on as a value
            case <-ctx.Done():
                return ctx.Err()
            case fetched <- Result[Page]{Err: wrapped}:
            }
            continue
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case fetched <- Result[Page]{Value: page}:
        }
    }
    return nil
})

One stage, both strategies. That hybrid is what makes classification worth the complexity.

Default unknown errors to Fatal.

Stopping on something you do not understand is recoverable; silently skipping it is not. Reclassify as Skip once you know what the error means and have decided that losing those items is acceptable. Getting this backwards produces silent data loss that looks like a successful run.

14.5.5 Fan-Out Stages

When one stage is the bottleneck, run N workers on it. The complication is that no single worker can close the shared output channel — closing while a sibling is still sending panics.

parallel_fetch_145.go
// Illustrative snippet — not a complete program
func parallelFetch(ctx context.Context,
    urls <-chan string) <-chan Result[Page] {
    out := make(chan Result[Page])

    go func() {
        defer close(out) // runs after g.Wait() below
        g, ctx := errgroup.WithContext(ctx)
        g.SetLimit(10)

        for url := range urls {
            g.Go(func() error {
                page, err := httpGet(ctx, url)
                select {
                case <-ctx.Done():
                    return ctx.Err()
                case out <- Result[Page]{Value: page, Err: err}:
                }
                return nil // errors travel as values, not through Wait
            })
        }
        _ = g.Wait()
    }()

    return out
}

The outer goroutine owns the channel and drives the group, so from the outside this stage looks like any other. defer close(out) runs after g.Wait() returns, which is what guarantees every send has finished before the close.

For a fan-out inside a pipeline-wide group, a WaitGroup tracks the workers and a closer goroutine handles the channel:

wg_145.go
// Illustrative snippet — not a complete program
var wg sync.WaitGroup
for range workers {
    wg.Add(1)
    g.Go(func() error {
        defer wg.Done()
        for raw := range rawCh {
            record, err := transformRecord(raw)
            if err != nil {
                return fmt.Errorf("transform: %w", err)
            }
            select {
            case <-ctx.Done():
                return ctx.Err()
            case recordCh <- record:
            }
        }
        return nil
    })
}

go func() {
    wg.Wait()
    close(recordCh)
}()

Two coordination mechanisms because they do different jobs: errgroup owns errors and cancellation across all stages, but it does not know which goroutines own which channel and so cannot close recordCh. The WaitGroup tracks exactly the workers that send on it.

When one worker fails: it returns an error, errgroup cancels ctx, and the siblings exit — but by two different routes, and the distinction matters. A worker blocked on recordCh <- record is freed by ctx.Done() in its select. A worker blocked on for raw := range rawCh is not — a receive does not watch the context. It exits only when the upstream stage closes rawCh, which happens because that stage was itself freed by the cancellation. Both routes end at wg.Done(), then close(recordCh), then the downstream for range ends.

Fan-out reorders.

Workers finish in whatever order the work takes, so results arrive in completion order rather than input order. If order matters, carry an index in the Result and sort after collection (§14.3.4).

14.5.6 Generator Errors

A generator produces the first channel, so it has nowhere upstream to send an error and no Result channel yet to put one in. The failure mode is silence:

read_records_145_x_1.go
// Illustrative snippet — not a complete program
const selectRecords = "SELECT id, data FROM records"

// ✗ BROKEN: consumer cannot tell "no records" from "query failed"
func readRecords(ctx context.Context, db *sql.DB) <-chan Record {
    out := make(chan Record)
    go func() {
        defer close(out)
        rows, err := db.QueryContext(ctx, selectRecords)
        if err != nil {
            return // error lost; channel just closes
        }
        defer rows.Close()
        // ... scan and send ...
    }()
    return out
}

Two fixes, and which one is right follows from the pipeline’s strategy.

Return a second channel when a generator failure is always fatal:

read_records_145_2.go
// Illustrative snippet — not a complete program
func readRecords(ctx context.Context,
    db *sql.DB) (<-chan Record, <-chan error) {
    out := make(chan Record)
    errCh := make(chan error, 1) // buffered: may never be read

    go func() {
        defer close(out)
        defer close(errCh)

        rows, err := db.QueryContext(ctx, selectRecords)
        if err != nil {
            errCh <- fmt.Errorf("query: %w", err)
            return
        }
        defer rows.Close()

        for rows.Next() {
            var r Record
            if err := rows.Scan(&r.ID, &r.Data); err != nil {
                errCh <- fmt.Errorf("scan: %w", err)
                return
            }
            select {
            case <-ctx.Done():
                return
            case out <- r:
            }
        }
        if err := rows.Err(); err != nil {
            errCh <- fmt.Errorf("rows: %w", err)
        }
    }()

    return out, errCh
}

The consumer checks <-errCh after the data channel is exhausted, as processLogs does in §14.5.2. Closing errCh is what makes that receive safe when nothing failed — it yields the zero value, nil.

Emit Result values when the pipeline is collect-all and a bad row is just another item to report. Then a scan error becomes Result[Record]{Err: ...} followed by continue, and the generator keeps reading. The difference between the two versions is exactly the difference between return and continue, which is the strategy made concrete.

14.5.7 Common Mistakes

No defer close(out) in a stage
Problem

Downstream for range never ends

Fix

Every stage closes its own output

Bare send with no ctx.Done()
Problem

Stage blocks forever; g.Wait() hangs

Fix

Wrap every send in a select

Dropping an error with continue
Problem

Consumer never learns the item failed

Fix

Forward it, or wrap and forward

Inspecting Value when Err is set
Problem

Garbage in, garbage downstream

Fix

Pass through untouched

One worker closing a shared channel
Problem

panic: send on closed channel

Fix

WaitGroup plus a closer goroutine

Reporting ctx.Err() as the failure
Problem

“context canceled” instead of a cause

Fix

Use g.Wait(), or context.Cause(ctx)

A generator that returns on error
Problem

Consumer cannot distinguish empty from failed

Fix

Second channel, or emit Result

The last one is worth expanding. When errgroup cancels the context, ctx.Err() is context.Canceled — the symptom, never the cause:

err_145_x_1.go
// Illustrative snippet — not a complete program
err := g.Wait()

// ✗ loses the actual failure
if ctx.Err() != nil {
    log.Fatal(ctx.Err()) // "context canceled"
}

// ✓ the root cause, with stage identity
if err != nil {
    log.Fatal(err) // "transform: field 'amount': invalid syntax"
}

And inside a stage that is shutting down and has no access to g.Wait(), context.Cause(ctx) gives the same root cause (§14.4.8) — which is how a stage logs why it is stopping while it stops.

Summary: Pipeline Error Handling

Pipelines differ from parallel groups in one structural way: the stages are connected, so an error either travels forward as data or backward as a cancellation, and the stages that did not fail have to unwind either way.

Propagating errors as values makes the first fallible stage an error boundary, converting <-chan T to <-chan Result[T]. Everything downstream forwards errors untouched. The pipeline assembles exactly as it would with no errors in it, and the price is the pass-through check in every stage.

Fail-fast makes each stage a member of one errgroup. Unwinding needs both mechanisms: context cancellation frees stages blocked on a send, channel closure frees stages blocked on a receive. Miss the select on a send and the stage hangs instead of failing.

Classification is the hybrid, and the rule that decides it is whether the error affects every remaining item or only this one. Unknown errors default to fatal, because stopping on something you do not understand is recoverable and silently skipping it is not.

Self-Check Questions: Pipeline Error Handling

Under collect-all, what happens to items 6 through 100 when item 5 fails in the transform stage? Under fail-fast?

Under collect-all, they are processed normally. Item 5's failure becomes a Result{Err: ...} and travels downstream alongside the successes; the consumer sees ninety-nine values and one error. The pipeline runs to completion.

Under fail-fast, the transform stage’s closure returns the error. errgroup cancels the derived context. The generator, blocked on a send, is freed by ctx.Done() and returns, and its defer close ends the transform stage’s for range, whose own defer close ends the consumer’s. g.Wait() returns item 5's error. Items 6 through 100 are never processed — some may already be in flight in a channel buffer and are simply dropped.

Cancellation flows upstream and closure flows downstream. Why do you need both?

Because a blocked goroutine is blocked in one of two ways, and each mechanism frees only one of them.

Without cancellation: a downstream stage fails and stops reading. The upstream stage is blocked in out <- value. Channel closure travels downstream, so nothing reaches it — the send never completes and the goroutine parks forever.

Without closure: an upstream stage exits on ctx.Done(). The downstream stage is blocked in for v := range in. Cancellation does not close a channel, so the range never ends and that goroutine parks forever.

Together they cover both: cancellation frees senders, closure frees receivers. Every stage is blocked as one or the other at any moment, so every stage is covered.

Find the bugs.

data_ch_145.go
// Illustrative snippet — not a complete program
g, ctx := errgroup.WithContext(context.Background())
dataCh := make(chan Data)

g.Go(func() error {
    defer close(dataCh)
    for _, url := range urls {
        data, err := download(url)
        if err != nil {
            return err
        }
        dataCh <- data
    }
    return nil
})
Two of them, and one turns a clean failure into a hang.

The send is unguarded. dataCh <- data has no select on ctx.Done(). If the consumer stage fails and stops reading, this goroutine blocks on the send forever. errgroup has cancelled the context, but nothing is watching it — so the goroutine never returns and g.Wait() never returns either. The program hangs rather than reporting the consumer’s error.

download does not take the context. Even with the send fixed, an in-flight download runs to completion after cancellation, because nothing told it to stop. Cancellation is cooperative: a function that does not accept a ctx cannot participate.

The first is the serious one — it converts a clean failure into a hang.

Worker 3 of ten in a fan-out stage fails. When do the other nine stop, and how?

It depends on what worker 3 returned, and the nine do not all stop the same way.

If it returned nil after sending a Result{Err: ...} downstream, the group sees no error, nothing is cancelled, and the other nine carry on. That is collect-all inside a fan-out.

If it returned the error, errgroup cancels the derived context and the nine exit by two different routes. A worker blocked on its output send is freed by ctx.Done() in the select. A worker blocked on for raw := range rawCh is not — a receive does not watch the context. It waits until the upstream stage closes rawCh, which happens because that stage was itself freed by the cancellation.

Both routes reach wg.Done(), the closer goroutine runs close(recordCh), and the downstream stage’s range ends. Worth being precise about, because “they see ctx.Done()” is only half true and the other half is what makes the upstream defer close load-bearing.

Key Takeaways

  • Stages are connected, so an error goes forward as data or backward as a signal — that is a design choice, not a spelling
  • The first fallible stage is the error boundary: <-chan T becomes <-chan Result[T], and everything downstream stays in Result[T]
  • Pass errors through untouched and never inspect Value when Err is set; dropping one with continue is §14.1 rebuilt inside a pipeline
  • Unwinding needs both directions: cancellation frees senders, closure frees receivers
  • Every stage: defer close(out), select on ctx.Done() around every send, return ctx.Err()
  • A worker blocked on a receive never sees ctx.Done() — it waits for the upstream close
  • Classify by blast radius: fatal if it affects every remaining item, skip if only this one, and default the unknown case to fatal
  • No worker can close a shared output channel; a WaitGroup and a closer goroutine do it
  • g.Wait() gives the root cause after the fact; context.Cause(ctx) gives it to a stage while the stage is still shutting down
Section 14.5 — in one line

Errors in a pipeline are either cargo or a stop signal — pick one per error, and remember that stopping takes two mechanisms because a blocked goroutine is either sending or receiving and each one frees only half of them.

14.6 Panics in Goroutines

Everything so far has been about errors — failures the code anticipated. A panic is the other kind: a nil dereference, an index out of range, a failed type assertion, a library that gave up. In sequential code one recover at the top of the stack contains all of them. In concurrent code that containment does not exist, and the consequence is not a lost error but a dead process.

14.6.1 Why Panics Are Different Here

recover works on one call stack. go creates a new one. A deferred recover in the parent runs on the parent’s stack and will never see a child’s panic:

RECOVERY DOES NOT CROSS go

Two call trees. In the sequential one, a single defer and recover at main catches a panic anywhere below it, in processA, helper, processB or parse, because all of them share one stack. In the concurrent one, processA and processB are started with go, so each has a separate stack; the recover at main catches nothing from either, and a panic in helper or parse ends the process unless that goroutine recovers on its own stack. Recovery does not cross a goroutine boundary.

Spawn a hundred goroutines and you need a hundred recovery points. Miss one and a panic in that one goroutine terminates the process, taking the ninety-nine that were working fine with it.

Recovering is not the same as being fine. The panic still interrupted whatever the goroutine had promised to do:

The goroutine was
Sending on a channel
Holding a mutex
Writing shared state
A member of an errgroup
A pipeline stage

What can panic, and what is beyond reach. Ordinary recoverable panics: nil pointer dereference, index out of range, failed type assertion, send on a closed channel, closing a closed or nil channel, a negative WaitGroup counter, an explicit panic() in a library. Writing to a nil map is also an ordinary panic and recover catches it normally.

These are not panics at all, and no defer runs for any of them:

Fatal condition
Concurrent map read/write
Stack overflow
All goroutines asleep
Out of memory

The nil-map/concurrent-map pair is the one readers most often confuse: m[k] = v on a nil map is recoverable; the same statement racing another goroutine’s write is not. Prevention is the only defence for the second — a mutex, or sync.Map.

14.6.2 Converting Panics to Errors

Four parts, and leaving out any one of them breaks it:

safe_do_146.go
// Illustrative snippet — not a complete program
func safeDo(fn func()) (err error) { // ① named return
    defer func() {                    // ② deferred function
        if r := recover(); r != nil { // ③ recover, called directly
            err = fmt.Errorf("panic: %v\n\n%s", r, debug.Stack()) // ④
        }
    }()
    fn()
    return nil
}

The named return is the part everyone forgets. Without it the deferred function has nothing to assign to, the function returns the zero value, and the caller is told everything succeeded.

recover must be called directly by the deferred function. A helper called by the deferred function gets nil — the specification says directly, and it means it.

The panic value can be any type. %v handles all of them; when it is already an error, %w preserves the chain for errors.Is and errors.As.

Capture the stack. debug.Stack() in the recovery defer includes the original panic site, which is worth stating plainly because it is widely doubted:

Terminal
panic: runtime error: index out of range [5] with length 0
runtime/debug.Stack()
main.safe.func1() main.go:15 ← the recovery defer
panic({...}) panic.go:860
main.deep(...) main.go:9 ← THE ORIGINAL PANIC SITE
main.middle(...) main.go:10
main.safe() main.go:18
Measured the deferred function runs while the panic is still propagating, before the frames are discarded, so the trace reaches all the way down to the panicking line. Without debug.Stack() the error reads panic: runtime error: index out of range and tells you nothing about where. With it you get a file and a line. Measured the wrapper is close to free on the path that matters. A bare call is 0.95 ns; adding a defer takes it to 2.83; adding recover inside that defer takes it to 4.14 — zero allocations either way. An actual panic-and-recover costs about 240 ns and one allocation. So a panic is roughly 250 times a function call and still absolutely cheap, which means the decision in §14.6.4 is about correctness and never about speed.

14.6.3 Recovery Wrappers

The boilerplate is identical every time, so wrap it once.

For errgroup:

with_recovery_146.go
// Illustrative snippet — not a complete program
func WithRecovery(fn func() error) func() error {
    return func() (err error) {
        defer func() {
            if r := recover(); r != nil {
                err = fmt.Errorf("panic: %v\n\n%s", r, debug.Stack())
            }
        }()
        return fn()
    }
}

g.Go(WithRecovery(func() error { return riskyOperation(ctx) }))

The group sees a recovered panic as an ordinary error: it cancels the context and g.Wait returns it.

For result channels, where a missing send deadlocks the receiver, put the send inside the defer so it happens on every exit path:

safe_go_146.go
// Illustrative snippet — not a complete program
// ch MUST be buffered to at least the number of SafeGo calls: this
// send happens even when the receiver has given up, and an unbuffered
// channel would park the goroutine here forever (§14.2.2).
func SafeGo[T any](ch chan<- Result[T], fn func() (T, error)) {
    go func() {
        var result Result[T]
        defer func() {
            if r := recover(); r != nil {
                result.Err = fmt.Errorf("panic: %v\n\n%s",
                    r, debug.Stack())
            }
            ch <- result // every path sends exactly once
        }()
        result.Value, result.Err = fn()
    }()
}

For fire-and-forget goroutines, recovery means logging and continuing — appropriate for cache warming, metrics, non-critical cleanup:

go_safe_146.go
// Illustrative snippet — not a complete program
func GoSafe(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                slog.Error("goroutine panic",
                    "panic", r, "stack", string(debug.Stack()))
            }
        }()
        fn()
    }()
}
When not to wrap.

A wrapper adds indirection: g.Go(WithRecovery(...)) makes a reader look elsewhere to find out what error handling is in place, where an inline defer is self-documenting. Since the cost is 1.3 ns and no allocations, the argument is entirely about design, not overhead. Wrap goroutines that touch untrusted input or third-party code. Do not wrap goroutines whose panics would be your own bugs — those you want to hear about immediately.

14.6.4 When to Recover and When to Crash

Recover at boundaries. Crash on invariants.

A boundary is where your code meets input you do not control: an HTTP handler, a worker consuming a queue, a pipeline stage reading a file, a call into a plugin. A panic there usually means “this input hit a path we did not handle”, and recovering is safe because the goroutine’s state is local to that one item, its siblings are working on different items, and the failure is attributable to something you can log and skip.

An invariant is an assumption your own code relies on — a pointer the constructor guaranteed non-nil, an index you already bounds-checked, a channel your protocol says is still open. A panic there means you have a bug, and recovering is dangerous: the bug may have corrupted state other goroutines depend on, continuing can turn a crash into wrong results or data loss, and the root cause goes unfixed because nothing surfaced.

RECOVER OR CRASH

Two columns setting the boundary for recovery. Recover for HTTP request handlers, worker pool jobs, pipeline stages, third-party or plugin code, and untrusted input, because the state involved is local to one item, siblings are unaffected, and the service stays up. Let it crash for internal logic bugs, invariant violations, corrupted shared state, configuration errors and missing critical resources, because the bug has to be found, continuing is unsafe, and the crash is the clearest possible signal. The litmus test: after recovering, is the program’s state still trustworthy? If yes, recover. If no, let it crash.

The standard library follows this exactly. net/http recovers a panic in a handler, logs the stack, and closes that connection — other requests are untouched. It does not recover in ListenAndServe; a listener failure is infrastructure and should stop the process. Recover at request boundaries, crash at infrastructure boundaries.

Selective recovery lets you take the expected panics and re-raise the rest:

err_146.go
// Illustrative snippet — not a complete program
defer func() {
    if r := recover(); r != nil {
        if _, ok := r.(runtime.Error); ok {
            err = fmt.Errorf("transform panic: %v\n\n%s",
                r, debug.Stack())
            return
        }
        panic(r) // an explicit panic() means an assertion failed
    }
}()

Recover, log, re-panic when the process should still die but you want structured logging or a metric first:

snippet_146.go
// Illustrative snippet — not a complete program
defer func() {
    if r := recover(); r != nil {
        slog.Error("fatal panic",
            "panic", r, "stack", string(debug.Stack()))
        metrics.PanicTotal.Inc()
        panic(r)
    }
}()
What re-panicking actually prints.

The runtime marks the panic [recovered, repanicked] on a single line, and — measured on go1.26.1 — the original panic site is still in the goroutine trace, below the re-panic frame. So you do not lose the location by re-panicking. Log the stack first for the reason that actually applies: you want structured output and a metric, which the runtime’s own crash dump on stderr cannot give you.

Put recovery at the unit of work, not around a batch of unrelated calls:

worker_146.go
// Illustrative snippet — not a complete program
func worker(jobs <-chan Order) {
    for order := range jobs {
        if err := safeProcess(order); err != nil {
            slog.Error("order failed", "order_id", order.ID, "err", err)
        }
    }
}

func safeProcess(order Order) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic processing order %s: %v\n\n%s",
                order.ID, r, debug.Stack())
        }
    }()
    return processOrder(order)
}

One panic affects one order. The worker logs it with the order ID and continues to the next.

14.6.5 Recovery and Resource Cleanup

Deferred functions still run during a panic — that is guaranteed. The subtlety is that some cleanup exposes broken state rather than tidying it.

defer mu.Unlock() prevents the deadlock, and hands the next goroutine whatever the panic left behind. When the protected data spans more than one statement, that is a real hazard:

store_146.go
// Illustrative snippet — not a complete program
type Store struct {
    mu    sync.Mutex
    data  map[string]Record
    index []string // must contain exactly the keys in data
}

func (s *Store) SafeUpdate(key string, rec Record) (err error) {
    s.mu.Lock()
    defer s.mu.Unlock() // ① registered first, so runs LAST

    defer func() { // ② registered second, so runs FIRST
        if r := recover(); r != nil {
            delete(s.data, key) // undo the write that did land
            err = fmt.Errorf("update panic: %v\n\n%s", r, debug.Stack())
        }
    }()

    s.data[key] = rec               // lands
    s.index = append(s.index, derive(rec)) // may panic: now
                                           // they disagree
    return nil
}

The two invariants — data and index holding the same keys — are broken between those two statements, which is exactly the window a panic can land in. LIFO ordering is what makes the repair possible: register the recovery defer after the unlock defer so it runs before it, and the state is consistent again by the time the lock is released.

The order is load-bearing, and so is the honesty

this only works when you can actually reverse what happened. A single-statement mutation like s.data[key] = transform(rec) cannot leave partial state at all — Go evaluates transform before the map write, so a panic inside it means the write never happened and there is nothing to undo. If you cannot say precisely what was modified and reverse it, do not recover. A crash beats silent corruption.

The same LIFO rule applies to a pipeline stage: register defer close(out) first and the recovery defer second, so recovery captures the error before the close signals downstream.

Cleanup that only releases is always safe during a panic — close(ch), wg.Done(), file.Close(), tx.Rollback(), conn.Close(). They run in LIFO order while the panic is still propagating, work normally because they do not call recover, and the recovery defer registered outermost runs last and stops the unwinding.

14.6.6 Common Mistakes

recover() called by a helper, not the defer
Problem

Returns nil; the process still dies

Fix

Call it directly in the deferred function

No named return
Problem

Caller sees nil; errgroup sees success

Fix

func() (err error)

Recovering without reporting
Problem

Silent failures, impossible to diagnose

Fix

Always log the value and the stack

Dropping debug.Stack()
Problem

An error with no location in it

Fix

Include it in production recovery

Mutating shared state before the risky call
Problem

Siblings read half-processed data

Fix

Mutate last, or do not recover

defer inside a loop
Problem

Recovery fires at function exit, not per item

Fix

Extract the body to a helper

Unbuffered channel in a deferred send
Problem

The recovery itself parks forever

Fix

Buffer to the sender count

The loop one is worth seeing, because it looks right:

snippet_146_x_2.go
// Illustrative snippet — not a complete program
// ✗ WRONG: one defer per iteration, none run until worker returns
for job := range jobs {
    defer func() {
        if r := recover(); r != nil {
            results <- Result{Err: fmt.Errorf("panic: %v", r)}
        }
    }()
    results <- Result{Value: process(job)}
}

With ten thousand jobs that is ten thousand stacked deferred functions, none of which runs until worker itself returns — and if job 5 panics, worker returns then, so jobs 6 onwards never run. Extracting the body into a safeProcess helper gives each job its own function scope and its own defer.

Summary: Panics in Goroutines

recover works on one stack and go makes a new one, so recovery does not cross a goroutine boundary. Every goroutine that might panic needs its own, and the one you miss takes the whole process down.

Converting a panic to an error needs four things together: a named return so the defer has somewhere to assign, a deferred function, recover called directly by it, and debug.Stack() for the location. The named return is the one that gets forgotten, and its absence turns a caught panic into a silent success.

The cost is not a factor. A recovery wrapper is about 1.3 ns over a bare defer and allocates nothing; an actual panic is around 240 ns. So whether to recover is decided entirely by whether the program’s state is still trustworthy afterwards.

Recover at boundaries — handlers, worker loops, stage entry points, third-party calls — where state is local and the failure is attributable. Crash on invariant violations, where the panic means your own assumption was wrong and continuing risks corrupt data. net/http draws the line in exactly this place, and some conditions are past reach entirely: a concurrent map access calls fatal() and no defer runs at all.

Self-Check Questions: Panics in Goroutines

Why can’t a parent’s defer/recover catch a child goroutine’s panic?

recover inspects the panic state of the goroutine whose stack the deferred function is running on. go creates a new goroutine with its own stack, and the parent’s deferred functions live on the parent’s stack — they are never invoked during the child’s unwinding.

There is no inheritance here to lean on, and no runtime relationship between the two goroutines at all (§14.1.1).

To get the failure back you need the same explicit mechanism as for any other error: the child recovers on its own stack, converts the panic to an error, and sends it through a channel, a Result, or a g.Go named return.

A goroutine in an errgroup panics with no recovery. Walk through what happens.

The runtime looks for a recover in that goroutine’s deferred functions and finds none — errgroup deliberately does not add one. It prints the panic value and the stack to stderr and calls exit(2).

The entire process is gone. g.Wait() never returns because nothing returns. The other goroutines in the group are terminated mid-execution, and their deferred functions do not run — no Close, no Rollback, no Done. Only the panicking goroutine’s own defers ran, during its unwinding.

The package’s reasoning is documented in the source: propagating the panic to Wait would delay it arbitrarily, reduce its stack to a value that crash-monitoring tools cannot see, and risk deadlocks that hide it entirely. Crashing loudly is the deliberate choice.

Your worker pool’s effective size drops from 10 to 8 to 5 over several hours. Is the cause unrecovered panics?

No — and this is worth being blunt about, because the “panics silently kill workers one at a time” story is common and wrong.

An unrecovered panic in any goroutine terminates the whole process.

Measured a ten-worker pool with one worker panicking on one job exits with status 2 and never reaches the line after wg.Wait(). You do not get a nine-worker pool; you get a core dump and a restart. If the pool is genuinely still serving traffic with fewer workers, no panic went unrecovered.

Real causes of gradual attrition, in rough order of likelihood: workers returning on an error the loop treats as fatal; workers blocked forever on an unguarded send to a channel nobody reads (§14.2.2), which shows in a goroutine dump as workers parked in chansend; a recover that catches the panic and then returns from the worker loop instead of continuing to the next job; or a supervisor that respawns on exit but not on a stuck goroutine.

The diagnostic that separates them is a goroutine dump: leaked workers are still there and parked, whereas exited ones are gone. And if you are seeing a shrinking pool at all, panic recovery is already present somewhere — worth finding, because a recover that swallows and returns is its own bug (§14.6.6).

Should you recover in a web handler, a CLI tool, and a plugin host?

Web handler: yes. Each request runs on its own goroutine with state local to that request. One malformed input should not kill in-flight requests for every other user. Recover per request, log with the stack, return 500. Note that net/http's built-in recovery closes the connection without a response — for an actual 500 status you need your own middleware.

CLI tool: no. The program does one job and exits; there is no service to protect. A panic with a full stack trace is the most useful possible output, and recovering would replace a precise location with a vaguer error message.

Plugin host: yes, always. You did not write the plugin and cannot audit it. This is the clearest case of a trust boundary, and it is worth combining with selective recovery so that runtime errors from the plugin become errors while an explicit panic from your own host code still crashes.

A goroutine holding a mutex panics and defer mu.Unlock() releases it. Why can that be worse than a deadlock?

Because a deadlock is loud and corruption is quiet.

A deadlock stops the program. You get a goroutine dump showing exactly what was waiting on what, and nothing wrong is written anywhere. Releasing the lock after a panic hands the next goroutine state that is halfway through a multi-step update — a map updated but its index not, a balance debited but not credited. The program keeps running and producing plausible, wrong answers, possibly for a long time before anyone notices.

Two strategies. Repair before releasing: register the recovery defer after the unlock defer so LIFO runs it first, undo the partial mutation, then let the unlock proceed on consistent state. Or do not recover, and let the crash happen.

The choice is entirely about whether you can state precisely what was modified and reverse it. A single-statement mutation cannot leave partial state to begin with; a multi-step one usually can, and the further apart the steps, the less likely a rollback is trustworthy.

Key Takeaways

  • recover works on one stack and go makes a new one — every goroutine needs its own, and one miss kills the process
  • The named return is what makes the conversion work; without it the caught panic becomes a silent nil
  • recover must be called directly by the deferred function, not by a helper it calls
  • debug.Stack() in the recovery defer does include the original panic site — the frames are still live while the panic propagates
  • The wrapper costs ~1.3 ns over a bare defer and zero allocations; a real panic is ~240 ns. Never decide this on cost
  • Recover at boundaries where state is local; crash on invariant violations where it is not
  • Concurrent map access, stack overflow, total deadlock and OOM call fatal()/throw() — no defer runs, no recover catches them. A nil map write is an ordinary recoverable panic
  • Re-panicking keeps the original site in the trace; log the stack first for structured output and metrics, not to preserve the location
  • LIFO ordering lets a recovery defer repair state before an unlock defer releases the lock — but only when the mutation is genuinely reversible
Section 14.6 — in one line

A panic in a goroutine is a process-wide event with a goroutine-sized fix, and the only question worth asking before recovering is whether anything you share is still trustworthy afterwards.

14.7 Partial Failure and Degraded Operation

Sections 14.2 through 14.6 built the mechanisms. This section is about the decision they hand you: some goroutines succeeded and some failed — now what?

14.7.1 The Partial Failure Problem

Sequential failure is binary. Each step depends on the last, so an error means the remaining steps cannot run and there is nothing to decide.

Concurrent failure is a count. Five fan-out fetches return three successes and two failures, and the three successes do not depend on the two failures in any way. Discarding them throws away completed work; using them without saying they are incomplete produces confident wrong answers.

Derived partial failure is not an edge case, and the arithmetic is worth doing once. If each goroutine fails independently with probability 1%, the chance that at least one of N fails is 1 − 0.99^N: about 39% at N=50 and 63% at N=100. A system doing hundred-way fan-out is in a partially-failed state on most requests. It is the normal operating condition, not the exception.
Strategy
Abort on first error
Ignore all errors
Degrade deliberately
The partial failure principle

the function that launches concurrent work should collect all outcomes. The caller decides the policy — abort, degrade, or continue. Baking all-or-nothing into a function that could have served partial results takes the choice away from the only code that knows what the results are for.

Returning both results and an error. Partial failure needs a function to return useful data and a non-nil error, which breaks Go’s usual convention that a non-nil error makes the other returns meaningless. That is fine as long as you say so:

fetch_all_147.go
// Illustrative snippet — not a complete program
// fetchAll fetches every URL concurrently.
//
// Both return values may be non-nil: results holds an entry for each
// URL in input order, and error describes the subset that failed. A
// nil error means every fetch succeeded. Callers that require
// completeness must check the error before using results.
func fetchAll(ctx context.Context,
    urls []string) ([]FetchResult, error) {
    results := make([]FetchResult, len(urls))
    var g errgroup.Group

    for i, url := range urls {
        g.Go(func() error {
            body, err := httpGet(ctx, url)
            results[i] = FetchResult{URL: url, Body: body, Err: err}
            return nil // never cancel a sibling
        })
    }
    g.Wait()

    var errs []error
    for _, r := range results {
        if r.Err != nil {
            errs = append(errs, fmt.Errorf("%s: %w", r.URL, r.Err))
        }
    }
    return results, errors.Join(errs...) // nil when nothing failed
}

Without that doc comment, callers apply the standard convention, see a non-nil error, and discard results they could have used.

14.7.2 Critical vs Optional

Not all concurrent work matters equally. The test is one question: can the caller do anything useful without this result?

Critical — failure aborts
Product info on a product page
Price in a checkout flow
Auth token for a protected API
Every input to a calculation

The classification is a domain decision, not a code pattern, and it moves with context: reviews are optional on a product page and critical on a reviews page.

The implementation is §14.4.5's selective return, applied per goroutine:

fetch_product_147.go
// Illustrative snippet — not a complete program
func fetchProductPage(ctx context.Context,
    id string) (*PageResponse, error) {
    var (
        product Product
        price   PriceInfo
        reviews []Review
        recs    []Product
    )
    var (
        mu      sync.Mutex
        missing []string
    )
    recordMissing := func(name string, err error) {
        mu.Lock()
        missing = append(missing, name)
        mu.Unlock()
        slog.Warn("optional fetch failed",
            "component", name, "err", err)
    }

    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error { // CRITICAL
        var err error
        product, err = catalog.Get(ctx, id)
        if err != nil {
            return fmt.Errorf("product catalog: %w", err)
        }
        return nil
    })

    g.Go(func() error { // CRITICAL
        var err error
        price, err = pricing.Get(ctx, id)
        if err != nil {
            return fmt.Errorf("pricing: %w", err)
        }
        return nil
    })

    g.Go(func() error { // OPTIONAL
        var err error
        if reviews, err = reviewSvc.List(ctx, id); err != nil {
            recordMissing("reviews", err)
        }
        return nil // never propagate
    })

    g.Go(func() error { // OPTIONAL
        var err error
        if recs, err = recSvc.ForProduct(ctx, id); err != nil {
            recordMissing("recommendations", err)
        }
        return nil
    })

    if err := g.Wait(); err != nil {
        return nil, err // a critical fetch failed
    }

    quality := Full
    if len(missing) > 0 {
        quality = Degraded
    }
    return &PageResponse{
        Data:    PageData{Product: product, Price: price,
                           Reviews: reviews, Recommendations: recs},
        Quality: quality,
        Missing: missing,
    }, nil
}

Each goroutine writes its own variable, so the data needs no synchronisation — g.Wait() supplies the happens-before edge. The shared missing slice does need the mutex, because several goroutines can append to it.

When to use two groups instead. One group launches everything at once, which is what you want when the work is independent. Use two sequential groups when the optional work depends on the critical results — personalised recommendations that need the product’s category, for instance. Two phases cost you latency, since the optional work cannot start until the critical work finishes; they buy you the dependency ordering and make the two policies structurally obvious rather than encoded in return values.

optional_147.go
// Illustrative snippet — not a complete program
// Phase 1: critical, fail-fast.
required, reqCtx := errgroup.WithContext(ctx)
required.Go(func() error { return loadCatalog(reqCtx) })
required.Go(func() error { return loadPricing(reqCtx) })
if err := required.Wait(); err != nil {
    return nil, err
}

// Phase 2: optional, collect-all, on a tighter budget of its own.
optCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond)
defer cancel()
var optional errgroup.Group
optional.Go(func() error { loadReviews(optCtx); return nil })
optional.Go(func() error { loadRecs(optCtx); return nil })
optional.Wait()
Two phases do not make the response faster

the second Wait still blocks for every optional goroutine, and now they start later. What bounds the damage is the shorter timeout on phase two, not the phase split. If your only problem is a slow optional service, give the single-group version a per-call timeout and keep the parallelism.

14.7.3 Threshold Policies

When goroutines all do the same work against different sources, importance is not per goroutine — it is a count. You need enough, not all.

query_with_147.go
// Illustrative snippet — not a complete program
// queryWithQuorum queries every replica and returns as soon as
// minRequired have succeeded.
//
// Both return values may be non-nil: the returned slice holds every
// success collected so far, and a non-nil error means the quorum was
// not reached. Callers that require the quorum must check the error.
func queryWithQuorum(
    ctx context.Context,
    replicas []string,
    query string,
    minRequired int,
) ([]QueryResult, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // release the losers on every return path

    ch := make(chan QueryResult, len(replicas)) // every send has a slot

    for _, addr := range replicas {
        go func() {
            data, err := queryReplica(ctx, addr, query)
            ch <- QueryResult{Replica: addr, Data: data, Err: err}
        }()
    }

    var successes []QueryResult
    failures := 0
    maxFailures := len(replicas) - minRequired

    for range replicas {
        r := <-ch
        if r.Err != nil {
            failures++
            if failures > maxFailures {
                return successes, fmt.Errorf(
                    "quorum failed: need %d, have %d, %d failed",
                    minRequired, len(successes), failures)
            }
            continue
        }
        successes = append(successes, r)
        if len(successes) >= minRequired {
            return successes, nil // enough; stop waiting
        }
    }
    return successes, nil
}

Two early exits, and both matter. Enough successes means stop waiting for slower replicas. Too many failures means the quorum is now arithmetically unreachable, so waiting for the rest cannot change the outcome.

Returning early leaves goroutines running, and the buffer at len(replicas) is what makes that safe — every send has a slot, so nobody blocks (§14.3.4). defer cancel() shortens their lifetime, which matters when they hold database connections.

Threshold
N of M
Majority
Percentage
All

Classification (§14.7.2) is for goroutines doing different kinds of work; thresholds are for goroutines doing the same work against different sources. They compose.

14.7.4 Time-Bounded Partial Results

Sometimes the bound is a deadline rather than a count: return whatever arrived when time runs out.

search_with_147.go
// Illustrative snippet — not a complete program
func searchWithDeadline(ctx context.Context, query string,
    backends []string) *SearchResponse {

    ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
    defer cancel()

    ch := make(chan BackendResult, len(backends))
    for _, name := range backends {
        go func() {
            hits, err := search(ctx, name, query)
            ch <- BackendResult{Backend: name, Hits: hits, Err: err}
        }()
    }

    var results []BackendResult
    for range backends {
        select {
        case r := <-ch:
            if r.Err != nil {
                slog.Warn("backend failed",
                    "backend", r.Backend, "err", r.Err)
                degradedBackends.Inc()
                continue
            }
            results = append(results, r)
        case <-ctx.Done():
            return buildResponse(results, len(backends)) // deadline hit
        }
    }
    return buildResponse(results, len(backends))
}

errgroup cannot do this. g.Wait() blocks until every goroutine returns, so there is no way to say “give me what you have after 200 ms” — you would get the results only after every goroutine noticed the cancellation and returned. The channel-and-select loop reads each result as it lands, which is what makes an early return possible.

Combining a deadline with a threshold gives the most robust policy — wait up to the deadline, then fail if what arrived is too thin:

results_147.go
// Illustrative snippet — not a complete program
var results []BackendResult
collect:
for range backends {
    select {
    case r := <-ch:
        if r.Err == nil {
            results = append(results, r)
        }
    case <-ctx.Done():
        // A bare "break" here would leave the select, not the loop.
        // The label is what exits the for. This is a common Go bug.
        break collect
    }
}

if len(results) < minResults {
    return nil, fmt.Errorf(
        "insufficient results: got %d, need %d (deadline %v)",
        len(results), minResults, deadline)
}
Timeout shape
Per operation
Collective
Hybrid

14.7.5 Fallback Strategies

A fallback converts a failure into a degradation. Tiers should run from highest quality to highest reliability:

get_recommendations_147.go
// Illustrative snippet — not a complete program
func getRecommendations(ctx context.Context, userID string) []Product {
    if recs, err := mlEngine.Recommend(ctx, userID); err == nil {
        return recs
    } else {
        slog.Warn("ML recs failed", "user", userID, "err", err)
    }

    if recs, err := popularity.ByCategory(ctx, userID); err == nil {
        return recs
    } else {
        slog.Warn("popularity recs failed", "user", userID, "err", err)
    }

    if recs, err := cache.Get(ctx, "trending"); err == nil {
        return recs
    } else {
        slog.Warn("trending cache failed", "user", userID, "err", err)
    }

    return nil // graceful absence: render the page without the section
}

Each tier is simpler, faster and more reliable than the one above it. The last tier is often graceful absence — return nothing and let the caller render a placeholder. That is the simplest fallback and frequently the right one; build more tiers only when the feature’s absence genuinely hurts.

Racing a fallback cuts latency when the primary is slow rather than broken — but only if you are honest about what it costs:

get_price_147.go
// Illustrative snippet — not a complete program
func getPrice(ctx context.Context, id string) (PriceInfo, error) {
    ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
    defer cancel()

    type attempt struct {
        price PriceInfo
        err   error
        tier  string
    }
    ch := make(chan attempt, 2)

    go func() {
        p, err := pricingSvc.Get(ctx, id)
        ch <- attempt{p, err, "realtime"}
    }()

    go func() {
        // Deliberately delayed: the cache is almost always faster, so
        // starting both at once would mean the cache always wins and
        // the real-time price would never be used. This gives the
        // authoritative source a head start and falls back only if it
        // is genuinely slow.
        select {
        case <-time.After(30 * time.Millisecond):
        case <-ctx.Done():
            // Still send. The loop below expects two values, and a
            // goroutine that returns without sending is exactly the
            // §14.3.7 deadlock -- one this would hit whenever the
            // caller has under 30ms left.
            ch <- attempt{err: ctx.Err(), tier: "cached"}
            return
        }
        p, err := priceCache.Get(ctx, id)
        ch <- attempt{p, err, "cached"}
    }()

    for range 2 {
        a := <-ch
        if a.err == nil {
            if a.tier == "cached" {
                slog.Info("served stale price", "product", id)
                stalePrices.Inc()
            }
            return a.price, nil
        }
    }
    return PriceInfo{}, fmt.Errorf(
        "all price sources failed for %s", id)
}
Every fallback tier trades quality for availability

make the trade explicit. ML recommendations beat popularity, which beats trending, which beats nothing. But if a fallback produces results that are misleading rather than merely less relevant — a stale price the user will be charged, a cached balance — graceful absence is safer than bad data.

14.7.6 Making Degradation Visible

The worst partial failure is the one nobody notices. If the recommendation service is down and the page silently renders without it, the system “works”, delivers less, and stays that way.

quality_147.go
// Illustrative snippet — not a complete program
type Quality int

const (
    Full     Quality = iota // everything present
    Degraded                // some optional data missing
    Minimal                 // critical data only
)

type PageResponse struct {
    Data    PageData
    Quality Quality  // how complete is this?
    Missing []string // what is absent?
}

Three signals, each for a different audience, and all three are needed:

Signal
Quality field in the response
Structured log with details
Metric counter or gauge
snippet_147.go
// Illustrative snippet — not a complete program
if resp.Quality != Full {
    w.Header().Set("X-Response-Quality", resp.Quality.String())
    slog.Warn("serving degraded response",
        "product_id", id, "quality", resp.Quality,
        "missing", resp.Missing)
    metrics.DegradedResponses.Inc()
}

Without the metric, operations does not know. Without the log, they cannot debug it. Without the quality field, the caller cannot adapt. Miss any one and degradation becomes invisible at that layer.

14.7.7 Common Mistakes

Treating partial results as complete
Problem

Wrong averages and totals, not approximate ones

Fix

Check the error before aggregating

All-or-nothing where partial would serve
Problem

An error page because one of eight widgets timed out

Fix

Return what you have plus a quality flag

Cascading degradation
Problem

One failure degrades every downstream service

Fix

Each service judges its own dependencies

Unbounded fallback latency
Problem

Response time multiplies with each tier

Fix

A shared deadline, plus a per-tier cap

Fallback as persistent state
Problem

Still on the fallback weeks after recovery

Fix

Decide per request; always try the primary

Degrading without logging
Problem

Nobody knows the system is impaired

Fix

Log and emit a metric on every fallback

Two need a note.

A shared deadline alone can starve the later tiers. Wrapping three tiers in one 5-second budget bounds the total, but the primary can consume all five seconds and leave nothing for the others — you have swapped 15 seconds of waiting for a guaranteed single attempt. The hybrid from §14.7.4 is the answer: a per-tier cap under an overall budget.

snippet_147_2.go
// Illustrative snippet — not a complete program
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // total budget
defer cancel()

for _, tier := range tiers {
    tierCtx, tierCancel := context.WithTimeout(ctx, 2*time.Second)
    d, err := tier.Get(tierCtx, id)
    tierCancel()
    if err == nil {
        return d, nil
    }
}

Sticky fallback flags never recover. if useFallback { return fallback.Get(...) } with a flag set on first failure means the primary is never tried again, so the system stays degraded after the outage ends — and the unsynchronised read and write of that flag is a data race the detector will flag. Make the fallback a per-request decision and recovery becomes automatic.

Summary: Partial Failure and Degraded Operation

Concurrent failure is a spectrum rather than a binary, and at any real fan-out width it is the normal state: at 1% per-goroutine failure, 63% of hundred-way requests have at least one failure. Designing as though “all succeeded” and “all failed” are the only cases means designing for something that rarely happens.

Collect every outcome and let the caller set the policy. When a function can return both useful data and a non-nil error, document that — silently breaking Go’s convention makes callers throw away results they could have used.

Four policies, and they compose. Classify goroutines doing different work as critical or optional. Threshold goroutines doing the same work against different sources. Bound with a deadline when latency matters more than completeness. Fall back from highest quality toward highest reliability, ending in graceful absence.

Then make it visible in three places at once — a quality field for the caller, a structured log for the on-call engineer, a metric for the dashboard. Degradation that nobody can see is not resilience; it is a permanent quality regression that has not been noticed yet.

Self-Check Questions: Partial Failure and Degraded Operation

The review service is down on a product page. Why is each of these wrong: HTTP 500, silently omitting reviews, or retrying three times?

HTTP 500 discards three successful fetches because one optional fetch failed. The page is entirely useful without reviews, and the user gets an error page instead.

Silent omission leaves nobody knowing. Operations has no alert, the product team sees engagement drop without a cause, and the outage persists because nothing surfaced it. The response was correct; the observability was missing.

Three retries adds latency to a request that is already going to be degraded. If the service is down rather than flaky, you have tripled the user’s wait to reach the same answer. Retries help with transient failures inside a deadline, not with an outage.

The right answer combines the good parts: classify reviews as optional, return the page, set Quality: Degraded with Missing: ["reviews"], log a warning, and increment a metric so an alert fires if the rate stays high.

When is partial failure worse than total failure?

When the caller cannot tell the data is incomplete and acts on it as though it were.

Financial reconciliation is the clearest case. Five payment processors, four respond, one times out. The system reports $47,000; the true figure including the missing processor is $62,000. That number is plausible — nothing about it looks wrong — so it flows into a report and nobody questions it until an audit.

Total failure would have produced “reconciliation unavailable”, which is an obvious signal that stops everything until it is fixed.

The rule that separates the cases: if partial data is misleading rather than merely incomplete, do not serve it. An average over seven of ten values is not approximately the average; it is a different number wearing the same label. For aggregations, require completeness. Where partial data has genuine value, always communicate the incompleteness alongside it.

Design the return policy for four search backends: product search, autocomplete, price comparison across at least three, inventory across all warehouses.

Product search — collect-all. Backends hold different products, so results merge and every responding backend adds value. Return what arrived and mark which sources are missing.

Autocomplete — first success wins. Any one backend can serve suggestions, and latency dominates the user experience. Return on the first response and cancel the rest — the quorum pattern with minRequired = 1.

Price comparison — threshold, minRequired = 3. Return early once three respond. If two or more fail, the comparison is too thin to be meaningful and could mislead someone into thinking they have seen the market. Return the error.

Inventory — fail-fast, all required. If one warehouse is unreachable you cannot state total stock. Selling something that is out of stock is worse than showing “temporarily unavailable”. errgroup.WithContext and return err.

Same fan-out shape four times; four different policies, each following from what the caller does with the answer.

Three fallback tiers each get their own 3-second timeout derived from the parent. What is wrong, and what is wrong with the obvious fix?

The problem: the budgets are independent, so worst-case total latency is nine seconds. The caller is waiting the whole time, and there is nothing in the code that says nine seconds is the bound.

The obvious fix is a single 3-second budget on the parent that all three tiers share. That bounds the total correctly.

What is wrong with it: the tiers can starve. If the primary uses all three seconds before failing, the replica and the cache each get a context that is already expired and fail instantly. You have replaced “nine seconds, three real attempts” with “three seconds, one real attempt” — better, but not what a fallback chain is for.

The complete answer is the hybrid: an overall budget on the parent and a per-tier cap derived from it, so the primary cannot consume everything and each tier gets a genuine attempt within the total.

Key Takeaways

  • At 1% per-goroutine failure, 39% of 50-way and 63% of 100-way fan-outs have at least one failure — partial failure is the normal state
  • Collect every outcome and let the caller set the policy; do not bake all-or-nothing into a function that could serve partial results
  • When both return values can be non-nil, document it — otherwise callers apply Go’s convention and discard usable data
  • Classify by “can the caller do anything useful without this?” — critical aborts, optional degrades
  • Two sequential groups buy dependency ordering, not latency; the shorter phase-two timeout is what bounds the damage
  • Threshold policies need two exits: enough successes, and enough failures that the quorum is unreachable
  • errgroup cannot return early, so time-bounded collection needs a channel and a select
  • Race a fallback only with a deliberate head start, or the faster-but-worse source always wins
  • A shared deadline bounds total latency but starves later tiers — cap each tier under the overall budget
  • Make every degradation visible in three places: quality field, structured log, metric
Section 14.7 — in one line

Concurrent failure is a count rather than a yes or no, so the real design question is how much working is enough — and whatever you answer, say out loud what was missing.

Chapter Summary

The go keyword discards return values, and it has to: a caller that does not wait has no moment at which to receive them. Everything in this chapter follows from rebuilding that path by hand.

Three mechanisms do it. An error channel carries errors alone, and its two failure modes are the same rule broken twice — a send that can block leaks a goroutine when the receiver leaves early and deadlocks when a WaitGroup is waiting on it. A result struct carries value, error and identity in one send, which is both more correct than two channels and cheaper than them, 432 bytes against 512. And errgroup carries the first error and cancels the rest, allocating about 40% less than the pattern it replaces.

Those three compose in two directions. Across a batch, errgroup for the lifecycle and an indexed slice for the output is the shape most production code lands on. Along a pipeline, an error either travels forward as a Result[T] value or backward as a cancellation — and unwinding needs both mechanisms, because a stage blocked on a send is freed only by the context and a stage blocked on a receive is freed only by a close.

Panics sit outside all of it. recover works on one stack and go makes a new one, so every goroutine needs its own and the cost is not the reason to skip it: a wrapper is 1.3 ns and no allocations, a real panic about 240 ns. The reason is whether the state you share is still trustworthy afterwards — recover at boundaries where it is, crash on invariant violations where it is not, and remember that a concurrent map access calls fatal() and is past reach entirely.

And then the decision the mechanisms hand back. At any real fan-out width, partial failure is the normal state rather than an exception: at 1% per-goroutine failure, 63% of hundred-way requests have at least one. Collect every outcome, let the caller pick the policy — classify, threshold, deadline, or fall back — and say out loud what was missing, in the return value, the log, and a metric.

Chapter Connections

How Chapter 14 connects
Chapter 2
§2.4's third question — “how are errors handled?” — is the one this chapter finally answers in full
Chapter 3
Sender-closes and close-as-broadcast are what §14.2.4's closer goroutine and every pipeline stage rely on
Chapter 4
Every guarded send in this chapter is §4.2's select; without it the fail-fast pipeline hangs instead of failing
Chapter 5
§14.2.2's buffer rule is §5.2's rule applied to errors — the leak and the deadlock are both an undersized buffer
Chapter 7
§14.5 is Chapter 7's pipeline and fan-out with the error question answered; the stage template already had the select
Chapter 8
§14.3.4's indexed writes are race-free for §8.4's reason: distinct addresses plus a happens-before edge
Chapter 10
§14.2.2's second failure mode is a §10.2 deadlock, and a goroutine dump is how you tell a leak from a crash
Chapter 12
errgroup is §12.1's sync.Once and §2.3's WaitGroup wired together — §14.4.8 opens it up
Chapter 13
errgroup.WithContext is a derived context per group, and WithCancelCause is why context.Cause names the failure
Chapter 15
Shutdown is this chapter’s g.Wait(), cancellation and drain, applied to a whole process

Final Checklist

Before moving to Chapter 15, ensure you can:

Exercise 14.1 — Report the Real Error, and Leave Nothing Running

Your move

Report the Real Error, and Leave Nothing Running

This fan-out is correct in every way the earlier chapters taught you to check. It compiles, go vet is clean, and go test -race finds nothing — every value it touches is local or a channel, so there is no data race here to find.

It still leaks two goroutines per call, and it blames the wrong thing for the failure.

The leak is a send. Each backend runs in its own goroutine and publishes to an unbuffered channel. When one backend fails, CollectAll returns immediately and stops receiving; the backends still in flight are cancelled, return promptly, and then park forever handing back a result nobody wants. §14.2.2's rule, turned around: every send needs somewhere to go, whether or not anyone is still listening.

The misattribution is subtler and it is the more interesting bug. CollectAll calls cancel() to stop the other backends, and then returns ctx.Err() — reasoning that the context is now done, so the context’s error must be the story. It is not. That cancellation is one this function caused, one line earlier, by observing the failure it is trying to report. The caller is told context canceled and learns nothing about which backend failed or why. This is §14.5.7's pitfall inside a plain fan-out: ctx.Err() is the symptom, and the error you already hold is the cause.

ch14/collect.go
// Package ch14 is the exercise for Chapter 14: Error Handling in
// Concurrent Code.
//
// CollectAll queries every backend at once and returns the payloads
// from those that answered. It is meant to hold three promises:
//
//   - when a backend fails, report that failure -- never the context
//     error that observing the failure caused
//   - leave no goroutine from the call still running once it returns
//   - return every payload when every backend succeeds
//
// TODO(reader): this compiles, vets clean, and has no data race. It
// keeps the third promise and breaks the first two. Two tests prove
// it. Each needs a different fix, and neither fix repairs the other.
package ch14

import (
	"context"
)

// Backend is one source CollectAll queries.
type Backend interface {
	Name() string
	Fetch(ctx context.Context) (string, error)
}

type reply struct {
	name    string
	payload string
	err     error
}

// CollectAll queries every backend concurrently. On the first failure
// it stops the others and reports that failure; if every backend
// succeeds it returns their payloads.
func CollectAll(ctx context.Context,
	backends []Backend) ([]string, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	ch := make(chan reply)

	for _, b := range backends {
		go func() {
			payload, err := b.Fetch(ctx)
			ch <- reply{name: b.Name(), payload: payload, err: err}
		}()
	}

	payloads := make([]string, 0, len(backends))
	for range backends {
		r := <-ch
		if r.err != nil {
			cancel()
			return nil, ctx.Err()
		}
		payloads = append(payloads, r.payload)
	}
	return payloads, nil
}

The four gates it has to satisfy:

ch14/collect_test.go
package ch14

import (
	"context"
	"errors"
	"runtime"
	"strings"
	"testing"
	"time"
)

var errBackendDown = errors.New("backend down")

type stub struct {
	name  string
	delay time.Duration
	fail  bool
}

func (s stub) Name() string { return s.name }

func (s stub) Fetch(ctx context.Context) (string, error) {
	select {
	case <-time.After(s.delay):
		if s.fail {
			return "", errBackendDown
		}
		return s.name + "-payload", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func backends() []Backend {
	return []Backend{
		stub{name: "alpha", delay: 5 * time.Millisecond},
		stub{name: "bravo", delay: 10 * time.Millisecond, fail: true},
		stub{name: "charlie", delay: 400 * time.Millisecond},
		stub{name: "delta", delay: 400 * time.Millisecond},
	}
}

// Gate 1: the reported error must be the backend's own failure.
func TestCollectAllReportsTheRealError(t *testing.T) {
	_, err := CollectAll(context.Background(), backends())
	if err == nil {
		t.Fatal("expected an error, got nil")
	}
	if errors.Is(err, context.Canceled) {
		t.Fatalf("reported the cancellation it caused\n"+
			"  got:  %v\n"+
			"  want: an error wrapping %v\n\n"+
			"  cancel() is how this function stops the other\n"+
			"  backends. Reporting ctx.Err() afterwards hides\n"+
			"  which backend failed, and why. Report the error\n"+
			"  you actually received.", err, errBackendDown)
	}
	if !errors.Is(err, errBackendDown) {
		t.Fatalf("error does not wrap the failure:\n  got: %v", err)
	}
	if !strings.Contains(err.Error(), "bravo") {
		t.Errorf("error should name the backend:\n  got: %v", err)
	}
}

// Gate 2: no goroutine from the call may outlive it.
func TestCollectAllLeavesNoBackendRunning(t *testing.T) {
	runtime.GC()
	before := runtime.NumGoroutine()

	for range 10 {
		_, _ = CollectAll(context.Background(), backends())
	}

	deadline := time.Now().Add(2 * time.Second)
	var after int
	for time.Now().Before(deadline) {
		runtime.GC()
		after = runtime.NumGoroutine()
		if after <= before {
			return
		}
		time.Sleep(20 * time.Millisecond)
	}

	t.Fatalf("goroutines outlived the call\n"+
		"  before: %d\n"+
		"  after:  %d  (%d left over from 10 calls)\n\n"+
		"  Two backends are still parked on their send when\n"+
		"  this function returns early. The receiver has gone,\n"+
		"  and an unbuffered channel needs one. Every send\n"+
		"  needs somewhere to go, listener or not.",
		before, after, after-before)
}

// Gate 3: the ordinary path must keep working.
func TestCollectAllReturnsEveryPayload(t *testing.T) {
	all := []Backend{
		stub{name: "alpha", delay: time.Millisecond},
		stub{name: "bravo", delay: 2 * time.Millisecond},
		stub{name: "charlie", delay: time.Millisecond},
	}
	payloads, err := CollectAll(context.Background(), all)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(payloads) != len(all) {
		t.Fatalf("got %d payloads, want %d", len(payloads), len(all))
	}
}

// Gate 4: a cancelled caller is still a context error.
func TestCollectAllHonoursCallerCancellation(t *testing.T) {
	slow := []Backend{
		stub{name: "alpha", delay: 2 * time.Second},
		stub{name: "bravo", delay: 2 * time.Second},
	}
	ctx, cancel := context.WithTimeout(
		context.Background(), 20*time.Millisecond)
	defer cancel()

	_, err := CollectAll(ctx, slow)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("caller's deadline should surface as\n"+
			"  DeadlineExceeded, got: %v", err)
	}
}

Run it:

Terminal
$ cd labs/go-concurrency/code/ch14
$ go test -race ./...

Two of the four gates fail, and they fail the same way every time:

Terminal
--- FAIL: TestCollectAllReportsTheRealError (0.01s)
    collect_test.go:50: reported the cancellation it caused
          got: context canceled
          want: an error wrapping backend down
          cancel() is how this function stops the other
          backends. Reporting ctx.Err() afterwards hides
          which backend failed, and why. Report the error
          you actually received.
--- FAIL: TestCollectAllLeavesNoBackendRunning (2.11s)
    collect_test.go:86: goroutines outlived the call
          before: 4
          after: 24 (20 left over from 10 calls)
          Two backends are still parked on their send when
          this function returns early. The receiver has gone,
          and an unbuffered channel needs one. Every send
          needs somewhere to go, listener or not.
FAIL
FAIL corebackend.dev/go-concurrency/ch14 2.596s
FAIL

The goroutine count is stable — ten runs on the reference machine gave twenty every time — because a goroutine parked on a send does not go anywhere.

Done when: go test -race ./... in code/ch14/ reports ok for all four tests, and keeps reporting it under -count=10.
Two traps: the two failures need two different changes, and each alone leaves the other failing — verified in both directions. Buffering the channel stops the leak and CollectAll still reports context canceled. Reporting the received error tells the truth and still strands two goroutines per call.

The other trap is TestCollectAllHonoursCallerCancellation, which exists to stop a fix that breaks ordinary use. When the caller’s deadline expires rather than a backend failing, the error should still be DeadlineExceededFetch returns it through the same reply.err field, so reporting what you received handles both cases without a special branch. A fix that reaches for ctx.Err() on some paths and not others will pass one gate and fail this one.

Where the files are: labs/go-concurrency/code/ch14/. A worked answer sits in solution/collect.go.txt, including a note on why the drain loop after cancel() is about determinism rather than leak-prevention (§14.3.6), and what changes if you rewrite the whole function with errgroup.

Further Reading

Next

You can now put a reason on a stop and get it back to the code that can act on it: size a buffer so no send can block, bundle a value with its error and its name, and hand a group of goroutines one context that the first failure cancels. You also know which of those claims survive measurement — that errgroup is cheaper than the pattern it replaces, that a drain loop buys determinism rather than leak-freedom, and that an unrecovered panic ends the process rather than thinning a pool. Chapter 15 applies all of it at once: graceful shutdown, where a signal arrives, in-flight work has to finish or be abandoned on a deadline, and every pattern in this chapter runs in reverse.