Chapter 13: Context

Chapter 2 asked one question of every goroutine you start: how does this one exit? Chapters 3 through 7 answered it with channels — close the channel, the receiver sees it, the goroutine returns. Chapter 4 gave that answer a name, the done channel, and it has been enough ever since.

This chapter is about the point where it stops being enough. Not because closing a channel is wrong, but because a real request is a tree of goroutines, and a tree needs three things a single channel cannot give it: a cancellation that cascades to descendants without being wired to each one, a deadline that children inherit and cannot extend, and a reason attached to the stop.

Consider three uses of the package that solves this. Every one of them compiles, passes go vet, and reports nothing under go test -race.

handle_search_13_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the handler returns, and nothing tells the work to stop
func handleSearch(w http.ResponseWriter, r *http.Request) {
    results := make(chan Result, 3)
    go searchDatabase(r.URL.Query().Get("q"), results)
    go searchCache(r.URL.Query().Get("q"), results)
    go searchAPI(r.URL.Query().Get("q"), results)

    var all []Result
    for i := 0; i < 3; i++ {
        all = append(all, <-results) // client left; we wait anyway
    }
    json.NewEncoder(w).Encode(all)
}
fetch_13_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: branches on the wrong error, so timeouts fall through
func fetch(ctx context.Context) error {
    if err := query(ctx); err != nil {
        switch {
        case errors.Is(err, context.Canceled):
            return errClientGone
        default:
            return err // an ancestor's deadline lands here, silently
        }
    }
    return nil
}
poll_13_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: one derived context per iteration, none of them released
func poll(ctx context.Context, urls []string) {
    for _, u := range urls {
        c, cancel := context.WithTimeout(ctx, time.Second)
        defer cancel() // runs when poll returns, not each turn
        get(c, u)
    }
}

The first is the problem context exists to solve. The second is a bug you can only find by knowing exactly which error a cancelled child reports — and the answer is not the one most tables print. The third is the mistake everyone makes once, and the reason usually given for it is wrong: nothing here leaks a goroutine. It leaks 115 bytes per iteration, and this chapter measures it.

That is the shape of the chapter. context.Context is a small interface — four methods, none of which do anything surprising on their own — wrapped around one idea: a tree in which cancellation flows down and nothing flows up.

WHAT TRAVELS THROUGH A CONTEXT TREE

A context tree rooted at Background. Below it a WithValue node, then a WithTimeout node, which branches into two WithCancel nodes, and one of those into two workers. An arrow down the right-hand side marks that values, deadlines and cancellation all travel downward only. Cancelling any node stops its entire subtree; cancelling a leaf leaves the parent untouched. A child may narrow the deadline it inherits but never widen it, and cancellation never travels upward.

What you’ll learn
  • Why a done channel stops scaling at the third level of nesting, and what context adds that channels cannot express
  • The four interface methods, what each guarantees, and what Err() actually costs before and after cancellation
  • Every constructor — WithCancel, WithTimeout, WithDeadline, WithCancelCause, WithoutCancel, AfterFunc — and which one a given requirement asks for
  • What cancel() really releases, measured, and why the usual explanation for calling it is a myth
  • How to tell a cancellation from a timeout when the failing context is three levels below the one that expired
  • WithValue's narrow legitimate use, the key-collision rule, and why lookup cost is a property of tree depth
What we’re not covering
  • Error propagation across goroutines and errgroup — Chapter 14. This chapter stops at returning ctx.Err()
  • Graceful shutdown of a whole process — Chapter 15, which builds on §13.3.5's detached contexts
  • sync.WaitGroup — §2.3, including the wg.Go form (Go 1.25) used throughout
  • The sync primitives context replaces for waiting — Chapter 12, and §12.4 is where sync.Cond cannot time out but a context can
Building toward

Every chapter so far coordinated goroutines that were already running. This one gives you the mechanism to stop them — all of them, in the right order, for a stated reason. Chapter 14 puts an error on that stop.

Prerequisites

The done channel from §4.3, because context is that pattern with a tree attached. Close-as-broadcast from §3.3 — Done() is exactly that, and §13.2.2 explains why a channel was the right choice. §2.4's four questions, because “how does this goroutine exit?” is the question context answers at scale. §8.3's distinction between a data race and a race condition, since every bug in this chapter is the second kind.

Which Go Are We On?

Every listing and figure in this chapter was run on Go 1.25 or later. Five changes matter when you compare this with older writing. Go 1.20 added WithCancelCause and Cause, which is how a cancellation carries a reason rather than just a sentinel. Go 1.21 added WithoutCancel, AfterFunc, WithTimeoutCause and WithDeadlineCause — §13.3.5 and §13.3.6 argue the first two change how you write cleanup. Go 1.23 made unreferenced timers collectable without Stop, which retires a great deal of advice about time.After leaking; §13.5.4 says what is left of it. And Go 1.24 added t.Context(), which is the right context for a test and is often miscited as 1.21. Go 1.27 removed the asynctimerchan GODEBUG that let older modules keep the pre-1.23 timers, so the semantics this chapter assumes now hold for every module regardless of its go line.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16. Every figure in this chapter came from running the benchmark as printed, each context freshly constructed inside its own function so no tree is shared between cases, -benchtime 1s -count 8, minimum of eight passes reported, -cpu 1 unless a figure says otherwise. Allocation counts are exact and reproduced identically across three separate machines; timings drifted by up to 50% between sessions on figures dominated by allocation, so treat the nanosecond values as good to a few nanoseconds and the ratios — and every allocation count — as the durable part.

13.1 Why Context Exists

A done channel cancels a set of goroutines. A request is a tree, and the difference shows up the first time a worker starts a worker.

13.1.1 The Orphaned Work Problem

The handler in this chapter’s opening spawns three searches and waits for all three. When the client disconnects at T1, nothing changes. The handler still blocks, the three searches still run, and the database connection, the cache round trip and the third-party API quota are all still spent on an answer nobody will read.

ORPHANED WORK AFTER THE CLIENT LEAVES

A timeline in three steps. At T0 a request arrives and the handler starts three searches. At T1 the client disconnects, but the handler is still blocked receiving and the searches do not know. At T2 all three finish and send. With a buffered channel the sends succeed and the results are discarded, so the connection and the API quota were spent for nothing. With an unbuffered channel the sends block forever and three goroutines leak. Neither case is a data race, so the race detector reports nothing.

The buffered case is the one worth sitting with, because it is not a leak in §2.5's sense — every goroutine exits. It is worse in a way that is harder to see: the program is correct and is spending real money on work it has already decided to throw away.

This is orphaned work: goroutines producing results no one will use. Cancellation is how you stop paying for it.

13.1.2 How Far a Done Channel Gets You

§4.3's pattern handles more than people give it credit for. One goroutine is trivial:

worker_131.go
// Illustrative snippet — not a complete program
func worker(id int, done <-chan struct{}) {
    tick := time.NewTicker(500 * time.Millisecond)
    defer tick.Stop()
    for {
        select {
        case <-done:
            return
        case <-tick.C:
            process(id)
        }
    }
}

Many goroutines are no harder, because closing a channel is a broadcast — §3.3's property, and the whole reason this pattern scales at all:

done_131.go
// Illustrative snippet — not a complete program
done := make(chan struct{})
for i := 1; i <= 3; i++ {
    go worker(i, done)
}
close(done) // all three see it

It breaks at the third level. Suppose the coordinator starts four workers, one of which starts two sub-workers, and you need three different cancellation scopes: cancelling main stops everything, cancelling the coordinator stops its workers but not the logger, and cancelling worker 2 stops only its own children.

WHERE ONE DONE CHANNEL RUNS OUT

A goroutine tree where a single done channel stops being enough. Main holds one done channel and starts a coordinator and a logger. The coordinator needs a second done channel for its four workers, and worker two needs a third for its own two sub-workers. Three independent cancellation scopes mean three channels wired by hand, and cancelling the coordinator means remembering to close every channel beneath it.

The wiring is the problem, not the channels. Each new scope adds a channel, every cancel site has to know every channel beneath it, and a select that must respect a global deadline, a per-item deadline and a manual stop is now watching three cases before it does any work — a shape §4.6 already warned gets unreadable fast.

Nested deadlines make it worse. “The whole operation gets 30 seconds; each fetch gets 5” is one sentence, and expressing it with timers means every child receives both timers, plus the done channel, plus whatever its own children need.

13.1.3 What Context Adds

Context is the done channel with three things attached: a tree, a clock, and a reason.

What channels alone cannot express:
Requirement
Broadcast a stop to many goroutines
Cascade the stop to descendants automatically
Narrow a deadline for one subtree
Inherit a deadline you cannot widen
Say why the work stopped
Carry request-scoped values down the tree

The same three-scope program becomes a shape with no wiring in it:

snippet_131.go
// Illustrative snippet — not a complete program
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go logger(ctx)

coordCtx, coordCancel := context.WithCancel(ctx)
defer coordCancel()
for i := 1; i <= 4; i++ {
    go worker(i, coordCtx)
}
// cancelling coordCancel stops W1-W4 and their children,
// and leaves logger running

Each derived context is a node. Cancelling a node cancels its whole subtree and nothing above it. No goroutine needs to know how many descendants it has.

13.1.4 When Context Is the Answer

Context is for cancellation, deadlines and request scope. It is not a general-purpose communication channel, and §13.6 spends most of its length on the one place people reach for it wrongly.

Reach for context when
The work belongs to a request that can be abandoned
Cancellation is the whole point
An operation must finish within a bound
Deadlines are inherited, not re-passed
Work fans out into goroutines you do not directly hold
The tree cascades for you
A value must follow a request across API boundaries
§13.6, for tracing-shaped data only
Do not reach for context when
Moving values between goroutines
A channel (§3.1)
Waiting for N goroutines to finish
sync.WaitGroup (§2.3)
Passing required function arguments
Parameters
Storing configuration or dependencies
Struct fields (§13.6.7)
Optional behaviour for one call
An options struct

13.1.5 Common Mistakes

Starting a goroutine without a context
Problem

Nothing can ever stop it; §2.4's exit question has no answer

Fix

Pass ctx as the first parameter

Cancelling with a bool and a mutex
Problem

Readers poll; no cascade, no deadline, no reason

Fix

WithCancel, and Done() for the wait

One done channel per scope, wired by hand
Problem

Every cancel site must know every channel below it

Fix

Derive a child context per scope

Treating context as the request’s data bag
Problem

Untyped, invisible to the compiler, unbounded lookup cost

Fix

Parameters; §13.6 for the exceptions

Summary: Why Context Exists

A done channel broadcasts a stop to a set of goroutines and does it well. What it cannot do is describe a hierarchy: it has no notion of a descendant, so every scope you add is a channel you wire and a select case you remember. It also has no clock and no vocabulary for why the work ended.

Context supplies those three. A derived context is a node in a tree; cancelling it stops that node’s entire subtree and nothing above it. A deadline set anywhere binds everything below and can be narrowed but never widened. And since Go 1.20, the stop can carry a reason.

Self-Check Questions: Why Context Exists

A buffered results channel means the search goroutines never block after the client disconnects. Is anything still wrong?

Yes, and it is the case worth understanding, because nothing that a tool checks is wrong with it.

Every goroutine exits, so there is no goroutine leak in §2.5's sense. There is no data race, so -race is silent. The program is correct.

It is also spending a database connection, a cache round trip and a third-party API call on a result that is discarded the moment it arrives. Under load, that is capacity you are paying for and throwing away. The failure is economic rather than mechanical, which is exactly why it survives code review — and why the fix is cancellation rather than a bigger buffer.

Closing a channel already broadcasts to every receiver. Why is that not enough for a tree of goroutines?

Because broadcast and hierarchy are different properties.

Closing a channel tells everyone holding that channel to stop. It says nothing about which goroutines are descendants of which, so it cannot express “stop the coordinator’s workers but leave the logger running.” To get that, you create a second channel and pass it to the right subset — and now every cancel site has to know every channel beneath it, forever, including the ones added next month.

Context keeps the broadcast — Done() is a channel that gets closed, exactly §3.3's mechanism — and adds the parent-child edge, so the cascade is structural rather than remembered.

Your handler needs a 30-second budget overall and a 5-second budget per fetch. What makes this awkward with timers and straightforward with contexts?

With timers, both budgets are values the code has to carry. Every child needs the global timer, its own timer and the done channel, and every select grows a case. Nothing enforces the relationship between the two budgets: a child can be handed a 60-second timer under a 30-second parent and no one notices.

With contexts, the outer budget is a property of the tree. WithTimeout(ctx, 30*time.Second) at the top and WithTimeout(ctx, 5*time.Second) per fetch produces a child whose effective deadline is the earlier of the two — automatically, because §13.4.3's rule is enforced by the constructor. The child cannot widen what it inherited, so the 30-second bound holds no matter what the per-fetch code asks for.

Key Takeaways

  • A done channel broadcasts a stop to a set of goroutines; it has no notion of a descendant, so every new scope is a channel you wire and remember
  • Context adds three things channels cannot express: a parent-child edge, an inherited deadline, and a reason for the stop
  • Orphaned work is not always a goroutine leak — a buffered result channel lets every goroutine exit while the program keeps paying for answers it will discard
  • Cancelling a node stops its whole subtree and nothing above it, so no goroutine needs to know how many descendants it has
  • Context is for cancellation, deadlines and request scope; moving values between goroutines is still a channel’s job
Section 13.1 — in one line

Channels broadcast a stop to a set; context propagates one through a tree, with a deadline and a reason attached — and no goroutine has to know how many descendants it has.

13.2 The Context Interface

Four methods, and every context in every Go program implements exactly these:

context_132.go
// Illustrative snippet — not a complete program
type Context interface {
    Done() <-chan struct{}
    Err() error
    Deadline() (deadline time.Time, ok bool)
    Value(key any) any
}

Two of them are about stopping, one is about time, one is about data. Nothing in the interface cancels anything — cancellation lives in the cancel function a constructor hands back, never in the context you pass around. That split is deliberate and §13.2.7 is about what it buys.

13.2.1 The Fundamental Guarantee

A context makes one promise: once Done() is closed, it stays closed, and Err() is non-nil forever after. There is no reset, no re-arm, no way back. A cancelled context is cancelled for the life of the program.

That is what makes the type safe to share. Any number of goroutines may hold the same context and read it concurrently, because after the single transition there is nothing left to observe.

13.2.2 Done(): The Cancellation Signal

Done() returns a channel that is closed when the context is cancelled. It is never written to — closing is the signal, which is §3.3's close-as-broadcast applied to an arbitrary number of waiters.

snippet_132.go
// Illustrative snippet — not a complete program
select {
case <-ctx.Done():
    return ctx.Err()
case result := <-work:
    return handle(result)
}

Why a channel rather than a callback or a flag? Because a channel is the only one of the three that composes with select. A flag has to be polled, and a poll cannot wait on anything else at the same time. A callback runs on somebody else’s goroutine and cannot be combined with the receive you actually care about. A channel lets one select wait on cancellation and on real work, which is the shape almost every context-aware function has.

Done() can return nil. For Background() and TODO() it does, because those contexts can never be cancelled:

ctx_132.go
// Illustrative snippet — not a complete program
ctx := context.Background()
fmt.Println(ctx.Done() == nil) // true

A receive on a nil channel blocks forever, which is exactly right: a context that will never be cancelled should never make the Done() case fire. select handles this correctly with no special casing, so code that treats Done() uniformly is already correct.

13.2.3 Err(): The Reason, and What It Costs

Err() returns nil while the context is live, and after cancellation returns one of exactly two sentinels:

The two sentinel errors
context.Canceled
Someone called cancel()
context.DeadlineExceeded
A deadline passed

Compare them with errors.Is, never ==, because the error you receive has usually been wrapped on its way up. The same rule applies to net.Error, which DeadlineExceeded implements with Timeout() == true:

net_err_132.go
// Illustrative snippet — not a complete program
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
    // handles both context and network timeouts, wrapped or not
}

Go 1.26’s generic errors.AsType[net.Error](err) returns the value and a bool without the declared variable, and go fix’s errorsastype modernizer performs the rewrite; either form is fine.

A bare err.(net.Error) type assertion is the common version of this and it is wrong for the same reason err == context.Canceled is wrong: one layer of fmt.Errorf("%w", ...) and it stops matching.

The Done/Err invariant. If Done() is closed, Err() is non-nil. If Err() is non-nil, Done() is closed. The two are never observed out of step — but the mechanism is worth knowing, because it is not what “atomic” would suggest. Inside cancel() the error is stored before the channel closes:

snippet_132_2.go
// Illustrative snippet — not a complete program
// $GOROOT/src/context/context.go, cancelCtx.cancel
c.err.Store(err)
// ... then
close(d)

Those are two separate operations, so for an instant the error is set while the channel is still open. The invariant holds anyway because Err() closes the gap from the other side:

snippet_132_3.go
// Illustrative snippet — not a complete program
// $GOROOT/src/context/context.go, cancelCtx.Err
if err := c.err.Load(); err != nil {
    <-c.Done()          // wait for the close before reporting
    return err.(error)
}
return nil

That extra receive is why Err() costs what it does, and the cost is not flat.

Measured minimum of eight runs, one cancelCtx per benchmark.
What Err() Costs
State
Live (Err() returns nil)
Cancelled (Err() returns the sentinel)
Background()
Derived the call gets roughly nine times more expensive at the exact moment it starts returning something. The live path is a single atomic load and nothing else; the cancelled path adds a receive on a closed channel to order the error against the close. This matters for the loop shape in §13.5.4: polling ctx.Err() on a hot path is cheap right up until cancellation, and then every remaining iteration pays nine times as much — which is fine, because there should only be one.

13.2.4 Deadline(): The Time Limit

Deadline() reports when the context will cancel itself, and whether it has such a time at all:

snippet_132_4.go
// Illustrative snippet — not a complete program
if deadline, ok := ctx.Deadline(); ok {
    if time.Until(deadline) < 100*time.Millisecond {
        return errNotEnoughTime // don't start what we can't finish
    }
}

The ok result is the whole point: false means no deadline was ever set anywhere in this context’s ancestry, and the returned time.Time is the zero value. Contexts from Background(), WithCancel and WithValue all report false unless something above them set a deadline.

This is the method to use when the work is adaptive — sizing a batch, choosing a retry budget, deciding whether an expensive call is worth starting. It is not how you detect cancellation; that is Done().

13.2.5 Value(): Request-Scoped Data

Value(key) walks up the tree looking for a matching key and returns nil if it reaches the root without finding one. It is the one method that is not about stopping, it is the one people misuse, and §13.6 is entirely about the narrow band where it is correct.

Two things matter here. The key must be an unexported type so no other package can collide with it, and the lookup is a walk, not a map read — §13.6.4 measures what that costs.

13.2.6 The Complete Behavior Table

This table has one row that is wrong in most references, and getting it wrong produces a real bug.

Context Behavior by Constructor
Context
Background(), TODO()
WithValue
WithCancel
WithTimeout, WithDeadline
WithoutCancel

The entry usually written for WithCancel is “context.Canceled”, and it is only true when this context’s own cancel was the thing that fired. A WithCancel child whose ancestor’s deadline expires reports the ancestor’s error:

bg_132.go
// Illustrative snippet — not a complete program
bg := context.Background()
parent, pc := context.WithTimeout(bg, 20*time.Millisecond)
defer pc()
child, cc := context.WithCancel(parent) // a plain WithCancel child
defer cc()

<-child.Done()
fmt.Println(child.Err())                    // deadline exceeded
fmt.Println(errors.Is(child.Err(), context.Canceled)) // false
The error you get names the cause, not the constructor

A WithCancel context can report DeadlineExceeded and a WithTimeout context can report Canceled. Any code that branches on one of these — and §13.5.2 shows the branch you will actually write — must handle both on every context, or the case it forgets falls through to the default and disappears.

13.2.7 Thread Safety and the Read-Only Contract

The interface has no setters, and that is the design. A context you receive can be observed and derived from, never modified and never cancelled:

WHO CAN DO WHAT

A two-column comparison of the creator of a context and a receiver of it. The creator holds the cancel function and can stop the subtree, and passes only the context downward. The receiver can read Done, Err, Deadline and Value, and can derive a narrower child, but cannot cancel what it was given. Cancellation authority stays with whoever created the scope, which is why cancel is a separate return value rather than a method on the interface.

All four methods are safe to call from any number of goroutines. Values stored via WithValue are safe only if the values themselves are immutable — the context protects its own structure, not your data (§13.6.7).

13.2.8 Common Mistakes

err == context.Canceled
Problem

Fails on a wrapped error

Fix

errors.Is(err, context.Canceled)

err.(net.Error)
Problem

Same wrapping problem, one layer down

Fix

errors.As(err, &netErr)

Assuming WithCancel implies Canceled
Problem

An ancestor’s deadline reports DeadlineExceeded

Fix

Handle both sentinels everywhere

Storing a context in a struct field
Problem

Ties one lifetime to an object with many

Fix

Pass it as the first parameter (§13.7.2)

Treating Deadline()'s zero time as “now”
Problem

Zero time with ok == false means no deadline

Fix

Always check ok first

Calling Value on a hot path
Problem

It is a tree walk, not a map read

Fix

Read once, pass the value down (§13.6.6)

Summary: The Context Interface

Four methods, one transition. Done() returns a channel that is closed on cancellation — close-as-broadcast, chosen over a flag or a callback because it is the only form that composes with select. It returns nil for contexts that can never be cancelled, and a nil channel blocking forever is the correct behaviour rather than a special case.

Err() is nil until cancellation and then reports one of two sentinels forever. Compare with errors.Is. The transition is not one atomic step: the error is stored before the channel closes, and Err() covers the gap by receiving on Done() before returning a non-nil error — which is why the call is 2.21 ns while live and 19.38 ns afterwards.

Deadline() is for adapting to the time you have, not for detecting the stop. Value() walks the tree. And the sentinel you get names the cause of cancellation, not the constructor that produced the context — the single most consequential row in the behavior table.

Self-Check Questions: The Context Interface

ctx.Done() returns nil for context.Background(). Why does that not break every select that waits on it?

Because a receive on a nil channel blocks forever, and “forever” is the correct answer for a context that can never be cancelled.

In a select, a case whose channel is nil is simply never ready — it is not an error and it does not panic. So select { case <-ctx.Done(): ...; case v := <-work: ... } with a Background() context behaves exactly as if the Done() case were not written, which is what you want: nothing will ever cancel it, so the other cases should decide.

This is why context-aware code never needs to check whether a context is cancellable. Uniform handling is already correct.

Err() is documented to be non-nil whenever Done() is closed. Is the transition atomic?

No, and the way the invariant is actually maintained is more interesting than an atomic transition would be.

Inside cancel(), c.err.Store(err) runs before close(d). Those are two operations, so there is a real window in which the error is set while Done() is still open. If Err() did nothing but load the field, a caller could see a non-nil error from a context whose channel had not closed yet, and the documented invariant would be false.

Err() closes the window from the reader’s side: on the non-nil path it does <-c.Done() before returning, so it cannot report an error until the channel is actually closed. The guarantee is real, it just lives in the reader rather than in a single atomic step — and it is the reason Err() costs about nine times more once the context is cancelled.

A context.WithCancel child sits under a parent created with WithTimeout. The parent’s deadline expires. What does the child’s Err() return?

context.DeadlineExceeded, not context.Canceled.

Cancellation cascades down carrying the originating error, so a child reports why the tree stopped rather than which constructor made it. errors.Is(child.Err(), context.Canceled) is false here.

This is the row most behavior tables get wrong, and it produces a specific bug: code that branches case errors.Is(err, context.Canceled): ... with everything else falling to default: will silently swallow every ancestor timeout, because it was written by someone who read that a WithCancel context reports Canceled. Handle both sentinels on every context.

Key Takeaways

  • Four methods, one irreversible transition: once Done() closes, Err() is non-nil forever and there is no reset
  • Done() returns a channel because only a channel composes with select; it returns nil for contexts that can never be cancelled, and a nil channel blocking forever is correct rather than a special case
  • Compare errors with errors.Is and interfaces with errors.As — a bare == or type assertion breaks on one layer of wrapping
  • The Done/Err invariant is not one atomic step: cancel() stores the error before closing the channel, and Err() covers the gap by receiving on Done() first
  • That extra receive is why Err() costs 2.21 ns while live and 19.38 ns once cancelled
  • The sentinel names the cause, not the constructor: a WithCancel context can report DeadlineExceeded
Section 13.2 — in one line

The interface only lets you observe — cancellation authority stays with whoever created the scope, and the error you observe tells you why the work stopped, not which constructor you called.

13.3 Creating Contexts

You never construct a Context directly. You start from a root and derive, and every derivation returns a new context — the parent is unchanged.

The Constructors
Function
Background()
TODO()
WithValue(parent, k, v)
WithCancel(parent)
WithDeadline(parent, t)
WithTimeout(parent, d)
WithCancelCause(parent)
WithoutCancel(parent)
AfterFunc(ctx, f)

13.3.1 Roots: Background() and TODO()

Both return a context that is never cancelled, has no deadline, no values, and a nil Done(). They are identical at run time — TODO() exists purely to say something to a human.

Which root to use
Background()
main, init, tests, and the top of a request you own
TODO()
You are mid-refactor and do not yet know what context belongs here

TODO() is a marker that survives code review and greps cleanly. Shipping it is the mistake — it means a cancellation path was never wired up, and nothing will ever stop that subtree.

13.3.2 WithCancel(): Manual Cancellation

snippet_133.go
// Illustrative snippet — not a complete program
ctx, cancel := context.WithCancel(parent)
defer cancel()

cancel is idempotent and safe from any goroutine; calling it twice does nothing the second time. Call it on every path — that is what defer is for.

Cancellation cascades immediately to the whole subtree:

snippet_133_2.go
// Illustrative snippet — not a complete program
root, rootCancel := context.WithCancel(context.Background())
a, aCancel := context.WithCancel(root)
defer aCancel()
b, bCancel := context.WithCancel(a)
defer bCancel()

rootCancel()
<-b.Done() // already closed: root -> a -> b

The reverse never happens. bCancel() stops b and anything below it, and a and root do not notice.

13.3.3 WithTimeout() and WithDeadline(): Time-Based

WithTimeout(parent, d) is WithDeadline(parent, time.Now().Add(d)). Use a timeout for “this operation gets 5 seconds” and a deadline when several operations share one absolute budget:

deadline_133.go
// Illustrative snippet — not a complete program
// one budget, many operations
deadline := time.Now().Add(30 * time.Second)
for _, step := range steps {
    ctx, cancel := context.WithDeadline(parent, deadline)
    err := step(ctx)
    cancel()
    if err != nil {
        return err
    }
}

Note cancel() inside the loop body rather than defer cancel() — §13.3.8 has the version that gets this wrong.

The effective deadline rule. A child’s deadline is the earlier of its own and its parent’s. It can narrow what it inherits; it can never widen it:

snippet_133_3.go
// Illustrative snippet — not a complete program
parent, pc := context.WithTimeout(bg, 5*time.Second)
defer pc()

child, cc := context.WithTimeout(parent, 1*time.Hour)
defer cc()

d, _ := child.Deadline() // 5 seconds from now, not an hour

This is enforced by the constructor, which is what makes “the whole request gets 30 seconds” a guarantee rather than a convention. No amount of optimism further down the tree can extend it.

A zero or negative timeout is already expired. It does not mean “no timeout”:

snippet_133_4.go
// Illustrative snippet — not a complete program
ctx, cancel := context.WithTimeout(bg, 0)
defer cancel()
fmt.Println(ctx.Err()) // context deadline exceeded

That bites when a duration comes from configuration and the field is missing.

13.3.4 WithCancelCause(): Cancellation with a Reason (Go 1.20+)

Err() tells you that something stopped; it has only two answers. WithCancelCause lets the canceller attach a real error, retrieved with context.Cause:

snippet_133_5.go
// Illustrative snippet — not a complete program
ctx, cancel := context.WithCancelCause(parent)

cancel(fmt.Errorf("upstream %s returned 503", host))

fmt.Println(ctx.Err())           // context canceled
fmt.Println(context.Cause(ctx))  // upstream cache-3 returned 503

Err() keeps returning the sentinel — that is deliberate, so existing code that branches on context.Canceled keeps working. Cause is the richer view layered on top.

How Cause behaves:
Situation
cancel(err) with a real error
cancel(nil)
Deadline fired
Plain WithCancel, cancelled
Not cancelled

The cause propagates down: a child of a cause-cancelled parent reports the same cause, so the goroutine that discovers the failure can be many levels below the one that named it. WithTimeoutCause and WithDeadlineCause (Go 1.21) do the same for deadlines.

13.3.5 WithoutCancel(): Detaching from the Parent (Go 1.21+)

Inherits values; drops cancellation and deadline:

detached_133.go
// Illustrative snippet — not a complete program
detached := context.WithoutCancel(ctx)

The use is cleanup that must finish even though the request it belongs to is over — while keeping the trace ID that makes the cleanup findable in a log:

handle_133.go
// Illustrative snippet — not a complete program
func handle(ctx context.Context) error {
    result, err := doWork(ctx)
    if err != nil {
        return err
    }

    // keep the values, drop the request's cancellation
    bg := context.WithoutCancel(ctx)
    // then give the cleanup a bound of its own
    cleanup, cancel := context.WithTimeout(bg, 5*time.Second)
    defer cancel()

    recordMetrics(cleanup, result)
    return nil
}

The two steps matter in that order. Detaching alone gives you work with no deadline at all, which is how a “quick” cleanup becomes a goroutine that outlives the process it was reporting on. Always re-bound after detaching.

Detaching is usually wrong

It severs the guarantee the rest of this chapter is built on. Reach for it only when the work genuinely must outlive the request — audit records, metrics, releasing a resource — and never as a way to silence a cancellation you found inconvenient.

13.3.6 AfterFunc(): Cleanup Callbacks (Go 1.21+)

Registers a function to run when a context is cancelled, and returns a stop you use to unregister it:

stop_133.go
// Illustrative snippet — not a complete program
stop := context.AfterFunc(ctx, func() {
    conn.Close()
})
defer stop()
What AfterFunc guarantees
Runs in its own goroutine
It never blocks the canceller
Runs at most once
However many ways the context is cancelled
stop() reports what happened
true if it prevented the call, false if already running or done
Already-cancelled context
The callback runs immediately

The stop return is not optional. Discarding it is the bug in most examples of this function:

acquire_133_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: closes twice if the caller closes normally first
func acquire(ctx context.Context) (*Resource, error) {
    res := &Resource{}
    context.AfterFunc(ctx, func() { res.Close() })
    return res, nil
}

Nothing here is a data race, and -race says nothing. The caller closes the resource when it is finished, the request ends a moment later, the callback fires, and res.Close() runs on an already-closed resource. Hand the caller the stop:

acquire_133_2.go
// Illustrative snippet — not a complete program
// ✓ the caller can retire the cleanup when it closes normally
func acquire(ctx context.Context) (*Resource, func(), error) {
    res := &Resource{}
    stop := context.AfterFunc(ctx, func() { res.Close() })
    release := func() {
        stop()
        res.Close()
    }
    return res, release, nil
}

AfterFunc is also how you bridge a context to an API that predates it — anything that takes its own done channel or has its own Close.

13.3.7 What cancel() Actually Releases

Almost every explanation of defer cancel() says it stops a leaked goroutine. That is not what happens, and the real answer is a better number.

Measured goroutine counts from runtime.NumGoroutine() after 1,000 constructions; heap from runtime.ReadMemStats around 100,000 children of one long-lived parent, with a runtime.GC() on each side.
WHAT 1000 CONSTRUCTIONS COST

Measured costs of constructing contexts. One thousand WithCancel calls and one thousand WithTimeout calls each add zero goroutines. For one hundred thousand children of a single long-lived parent, not calling cancel leaves the heap 11.55 megabytes larger, about 115 bytes per child, while calling cancel returns the heap to its starting size. In both cases the goroutine count is unchanged.

WithCancel adds the child to the parent’s children map — a map insert, no goroutine. WithTimeout arms a runtime timer through time.AfterFunc, which is an entry in the timer heap, not a parked goroutine. A goroutine appears in exactly one case: propagateCancel's fallback path, when the parent is a custom Context implementation that the package cannot inspect, and it must watch parent.Done() itself. That is rare and it is not what happens with the standard constructors.

So the advice is right and the usual reason is wrong. cancel() removes the child from the parent’s map and stops the timer. Skip it under a long-lived parent and you accumulate 115 bytes per orphaned child, held for as long as the parent lives — which for a server whose root context lives for the life of the process means until restart.

Derived at 10,000 requests per second, one missed cancel() per request is about 1.1 MB of unreclaimable heap per second. That is a memory leak with an ordinary profile signature, not a goroutine leak — so the goroutine dump that §10.4 taught you to read will look perfectly healthy while the process grows.

13.3.8 Common Mistakes

Discarding cancel with _
Problem

The child is never unlinked from the parent

Fix

Always bind it and call it

defer cancel() inside a loop
Problem

Every deferral waits for the function to return

Fix

Call cancel() at the end of the iteration

Expecting a child to widen a deadline
Problem

The constructor takes the earlier of the two

Fix

Derive from a parent with the budget you need

WithTimeout(ctx, 0) for “no timeout”
Problem

Zero is already expired

Fix

Use WithCancel, or omit the deadline

Reusing a cancelled context
Problem

It stays cancelled forever

Fix

Derive a fresh one from a live parent

Passing nil as a context
Problem

Panics on the first method call

Fix

context.TODO() while refactoring

Discarding AfterFunc's stop
Problem

Cleanup runs even after a normal close

Fix

Return stop to whoever owns the resource

Shipping context.TODO()
Problem

Nothing can ever cancel that subtree

Fix

Wire the real context before merging

The loop case is worth seeing, because it looks correct:

poll_133_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: 1000 contexts, all released when poll returns
func poll(ctx context.Context, urls []string) {
    for _, u := range urls {
        c, cancel := context.WithTimeout(ctx, time.Second)
        defer cancel()
        get(c, u)
    }
}

// ✓ released at the end of each iteration
func poll(ctx context.Context, urls []string) {
    for _, u := range urls {
        c, cancel := context.WithTimeout(ctx, time.Second)
        get(c, u)
        cancel()
    }
}

If get can panic, put the body in a closure so the defer still runs per iteration. The measured cost of getting this wrong is §13.3.7's 115 bytes times the length of the loop.

Summary: Creating Contexts

Start from Background() and derive. WithCancel gives you a stop you control; WithTimeout and WithDeadline give you one the clock controls, and a child’s deadline is always the earlier of its own and its parent’s — narrowing is allowed, widening is not, and the constructor enforces it.

WithCancelCause (1.20) attaches a real error to the stop while leaving Err()'s two sentinels intact, so existing branches keep working. WithoutCancel (1.21) detaches from cancellation while keeping values, and should always be followed by a fresh bound. AfterFunc (1.21) registers cleanup and hands back a stop that is not optional.

And cancel() releases a map entry and a timer, not a goroutine. The constructors create no goroutines at all under a standard parent. Missing the call costs 115 bytes per child for as long as the parent lives.

Self-Check Questions: Creating Contexts

What does defer cancel() actually release, and what happens if you skip it?

It removes the child from its parent’s children map and, for WithTimeout/WithDeadline, stops the timer.

It does not stop a goroutine, because none was started. Measured on the reference machine, 1,000 WithCancel calls and 1,000 WithTimeout calls each add zero goroutines. WithCancel does a map insert; WithTimeout arms a runtime timer via time.AfterFunc, which is a timer-heap entry. The one exception is a custom Context implementation as the parent, where the package cannot register a child and must spawn a watcher instead.

Skipping the call leaks memory rather than goroutines: 115 bytes per orphaned child, retained as long as the parent lives. Under a server’s root context that means until the process restarts, and the goroutine dump stays completely healthy while the heap grows.

defer cancel() inside a loop body — what goes wrong, and what does it cost?

defer is function-scoped, not block-scoped, so none of the deferred calls run until the enclosing function returns. A loop over 1,000 URLs builds 1,000 live children of the same parent and releases them all at the end.

The cost is §13.3.7's number: 115 bytes each, so about 115 KB held for the duration of the function, plus 1,000 live timer entries. Not fatal in one function, and steadily fatal in a long-lived worker that does this per item.

Call cancel() explicitly at the end of the iteration. If the body can panic, wrap it in a closure so a defer inside that closure runs per iteration.

A parent has 5 seconds left. You call context.WithTimeout(parent, 1*time.Hour) on it. When does the child’s deadline fire?

In 5 seconds. child.Deadline() returns the parent’s time, not an hour from now.

WithDeadline compares the requested deadline with the parent’s and keeps the earlier one, so a child can only ever narrow the budget it inherited. That is what makes an outer bound a real guarantee: once the top of a request says 30 seconds, nothing further down can extend it, however optimistic its own timeout is.

If you genuinely need the longer budget, the work does not belong under that parent — derive it from one with the budget you need, or detach with WithoutCancel and set a fresh bound (§13.3.5), knowing you have taken it out of the request’s scope.

Key Takeaways

  • Start from Background() and derive; TODO() is a marker for a context you have not wired yet, and shipping it means nothing can cancel that subtree
  • A child’s deadline is the earlier of its own and its parent’s — narrowing is allowed, widening is impossible, and the constructor enforces it
  • A zero or negative timeout is already expired, not “no timeout”
  • WithCancelCause attaches a real error while leaving Err()'s two sentinels intact, so existing branches keep working
  • WithoutCancel keeps values and drops cancellation; always give the detached work a fresh bound
  • AfterFunc returns a stop that is not optional — discarding it means cleanup still runs after a normal close
  • The constructors start no goroutines. cancel() releases a map entry and a timer, and skipping it costs 115 bytes per child for as long as the parent lives
Section 13.3 — in one line

Every constructor derives a narrower child and hands back the authority to stop it — and defer cancel() is about 115 bytes and a timer, not the goroutine everyone says it is.

13.4 The Context Tree

Every derived context is a node with exactly one parent. That single edge is the whole data structure, and everything in this chapter follows from which direction each thing travels along it.

13.4.1 How Trees Form

A tree is built by derivation, top down:

root_134.go
// Illustrative snippet — not a complete program
root := context.Background()
reqCtx := context.WithValue(root, requestIDKey{}, id)
opCtx, cancel := context.WithTimeout(reqCtx, 30*time.Second)
defer cancel()

for _, shard := range shards {
    go query(opCtx, shard) // all three share opCtx
}
DIRECTION OF TRAVEL

A context tree showing which way each property moves. Background is at the root, then a WithValue node, then a WithTimeout node that branches into two WithCancel nodes feeding three query goroutines. An upward arrow marks that value lookups resolve upward toward the root; a downward arrow marks that cancellation and deadlines flow downward. Cancelling a node stops its whole subtree, and cancelling a leaf is invisible to everything above it.

What each thing does:
Property
Cancellation
Deadline
Values
Errors

Nothing travels upward except a value lookup, and that is a read.

13.4.2 Cancellation Propagates Down

Cancelling a node closes its Done() and every descendant’s, carrying the originating error:

snippet_134.go
// Illustrative snippet — not a complete program
root, rootCancel := context.WithCancel(bg)
mid, midCancel := context.WithCancel(root)
defer midCancel()
leaf, leafCancel := context.WithCancel(mid)
defer leafCancel()

midCancel()

fmt.Println(root.Err()) // <nil>  — unaffected
fmt.Println(mid.Err())  // context canceled
fmt.Println(leaf.Err()) // context canceled

The propagation is synchronous within cancel(): by the time it returns, every descendant’s Done() is closed. What is not synchronous is the goroutines reacting — they wake when the scheduler gets to them, so cancel() returning does not mean the work has stopped. §13.7.5 is about waiting for that properly.

13.4.3 Deadlines Are Inherited, Never Extended

The rule from §13.3.3, stated as a tree property: a node’s effective deadline is the earliest deadline anywhere on the path from it to the root.

EFFECTIVE DEADLINE IS THE EARLIEST ANCESTOR'S

A four-level chain showing deadline inheritance. The root has no deadline. Node A sets a thirty second timeout and fires at thirty seconds. Node B asks for sixty seconds but still fires at thirty, because it cannot widen what it inherited from A. Node C asks for five seconds and fires at five, because narrowing is allowed. A node’s effective deadline is the earliest one anywhere on its path to the root.

This is why a request-level budget is trustworthy. It is also why a slow dependency cannot be given “just a bit more time” by the function that calls it — the only way to get a longer budget is to sit outside the scope, which §13.3.5 makes explicit and deliberate.

13.4.4 Values Resolve Upward

Value(key) starts at the node and walks toward the root, returning the first match:

ctx_134.go
// Illustrative snippet — not a complete program
ctx := context.WithValue(bg, userKey{}, alice)
child := context.WithValue(ctx, traceKey{}, "abc123")

child.Value(userKey{})  // alice — found two levels up
ctx.Value(traceKey{})   // nil   — the parent cannot see the child

A key set closer to the node shadows the same key set higher up, which is occasionally useful and more often a bug you cannot see. §13.6.4 measures what the walk costs.

13.4.5 Deriving a Child Is O(depth), Not O(1)

This is stated as constant-time nearly everywhere, and it is not. WithCancel has to find the nearest cancellable ancestor to register with, and it does that with a value lookup:

snippet_134_2.go
// Illustrative snippet — not a complete program
// $GOROOT/src/context/context.go, parentCancelCtx
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)

That is the same upward walk as any other Value call, so the cost of creating a child grows with how deep you already are.

The chain has to be rooted at something cancellable for that to be the code doing the work, and it is worth seeing why. propagateCancel gives up before it ever reaches parentCancelCtx if there is nothing above it to register with:

done_134.go
// Illustrative snippet — not a complete program
// $GOROOT/src/context/context.go, cancelCtx.propagateCancel
done := parent.Done()
if done == nil {
    return // parent is never canceled
}

Under a Background() root, Done() is nil and that early return fires, so a benchmark built on WithValue over Background() measures only Done() chaining through each node — the right shape, the wrong mechanism. A real request tree is rooted at something cancellable, where both walks run and the child is inserted into the ancestor’s map.

Measured minimum of eight runs, WithCancel plus cancel per iteration, under a chain of WithValue nodes rooted at a WithCancel context.
Cost of Deriving a Child
Parent depth
0
10
50
Derived the allocation is flat and the time is not — a child at depth 50 costs roughly 4.9× one at depth 0, and every nanosecond of that is the walk. Rooting the same chain at Background() instead gives 143.90, 185.70 and 678.50 ns, about 1.7× cheaper at every depth, because the early return above skips both the lookup and the map insert. So “don’t worry about tree depth” is wrong twice over: deep trees make lookups slower (§13.6.4) and they make derivation slower too.

In practice a request tree is a handful of levels deep and none of this matters. It starts mattering when a chain of middleware adds a WithValue each, a library adds a few more, and something in a loop derives a child per item — which is precisely the shape §13.6.5's “bundle related values” advice exists to prevent.

13.4.6 Designing the Tree

Principles that hold up
Match the tree to the logical structure
One node per unit of work that can be abandoned independently
Cancel at the level that owns the decision
The function that created the scope calls cancel
Bound anything that talks to the outside
Every network or disk call gets a deadline from somewhere above
Never break the chain
Pass the context you were given; derive, do not start over
Keep it shallow
§13.4.5 and §13.6.4 both charge by depth

Breaking the chain is the one that hides best:

fetch_134_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the caller's cancellation and deadline are discarded
func fetch(ctx context.Context, url string) error {
    c, cancel := context.WithTimeout(bg, 5*time.Second) // not ctx!
    defer cancel()
    return get(c, url)
}

// ✓ derived, so the caller's budget still binds
func fetch(ctx context.Context, url string) error {
    c, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    return get(c, url)
}

The broken version passes every test that checks the 5-second timeout, and quietly ignores a client that hung up 4 seconds ago.

13.4.7 Debugging a Context Tree

Two questions come up in production, and they have different tools.

“What cancelled this?” Err() gives you the kind and Cause gives you the reason, but neither says which node. Build the tree with WithCancelCause at every point where a distinct thing can go wrong, and the answer arrives with the error:

snippet_134_3.go
// Illustrative snippet — not a complete program
sub, cancel := context.WithCancelCause(ctx)
// ...
cancel(fmt.Errorf("shard %d: %w", id, err))

// far below, in a worker that only sees sub:
if err := ctx.Err(); err != nil {
    log.Printf("stopping: %v", context.Cause(ctx))
}

Without a cause, every cancellation in a large tree reports the same two words and you are left correlating timestamps.

“Why is the heap growing?” This is §13.3.7's failure, and the important thing is that the usual tool says nothing. A missing cancel() leaks no goroutines, so the dump §10.4 taught you to read looks perfectly healthy:

WHERE A MISSING cancel() SHOWS UP

Which tool detects a missing cancel call. The goroutine dump shows nothing, because counts stay flat. The race detector shows nothing, because there is no data race. Go vet shows nothing, because the code compiles. Only the heap profile shows it, as growth inside context.propagateCancel retained by the parent’s children map. Look for a live parent holding a map that only ever grows, at about 115 bytes per orphaned child.

go tool pprof -inuse_space on a heap profile is the tool. The retaining path runs back to whichever long-lived context is the parent — usually the one created in main.

13.4.8 Common Mistakes

Deriving from Background() mid-tree
Problem

Discards the caller’s cancellation and deadline

Fix

Derive from the ctx you were given

A node nobody holds cancel for
Problem

The subtree can only be stopped from above

Fix

Keep cancel next to the work it owns

Cancelling the parent to stop one child
Problem

Takes down siblings that were fine

Fix

Derive a child per independently cancellable unit

One WithValue per middleware
Problem

Deepens every lookup and every derivation

Fix

Bundle related values in one struct (§13.6.5)

Expecting a leaf’s cancel to reach the parent
Problem

Nothing propagates upward

Fix

Return an error; Chapter 14 is about carrying it

Assuming cancel() returning means work stopped
Problem

It closes channels; goroutines wake later

Fix

Wait for them (§13.7.5)

Summary: The Context Tree

One parent per node, and direction is everything. Cancellation and deadlines flow down; a value lookup is the only thing that travels up, and it is a read. Cancelling a node stops its whole subtree synchronously — every descendant’s Done() is closed by the time cancel() returns — and touches nothing above it.

A node’s effective deadline is the earliest one on its path to the root, which is what makes an outer budget a guarantee rather than a suggestion. Deriving a child is O(depth) rather than O(1), because WithCancel finds its nearest cancellable ancestor with the same upward walk that Value uses: 237 ns at depth 0 against 1,171 ns at depth 50.

Self-Check Questions: The Context Tree

Deriving a child context is usually described as O(1). Why is that wrong, and when does it matter?

Because WithCancel has to register the new child with the nearest cancellable ancestor, and it finds that ancestor with parent.Value(&cancelCtxKey) — the same upward walk as any other value lookup. The cost therefore scales with how deep the parent already is.

Measured under a cancellable root, which is what a request tree has: 237 ns at depth 0, 320 ns at depth 10, 1,171 ns at depth 50 — about 4.9× across that range, with allocation flat at 96 B and 2 allocs. The time is the walk.

Root the same chain at Background() and every figure drops by roughly 1.7×, because propagateCancel returns early when parent.Done() is nil and never performs the lookup or the map insert at all. A benchmark built that way measures the right shape for the wrong reason.

For an ordinary request tree, a few levels deep, none of this shows up. It starts to matter when several layers of middleware each add a WithValue, a library adds more, and something derives a child per item in a loop — the same shape that makes lookups slow, for the same reason.

A leaf goroutine hits an unrecoverable error. Can it cancel the whole request by cancelling its own context?

No. Cancelling a leaf stops the leaf and anything below it; the parent never notices. Nothing propagates upward.

To stop the request, the leaf has to communicate the failure to whoever owns the scope — return an error, send on a channel, or hold a cancel function that was created higher up and deliberately handed down. The last one works but inverts the ownership the design is built on, so it should be rare and obvious.

The idiomatic answer is Chapter 14's: errgroup gives the group a shared context, and the first goroutine to return an error cancels it for everyone. That is exactly this pattern packaged, with the ownership kept at the level that created the group.

cancel() has returned. Is the work stopped?

No. cancel() closes Done() on the node and every descendant, and that part is synchronous — by the time it returns, every descendant reports a non-nil Err().

What it does not do is run anybody else’s code. The goroutines watching those channels wake when the scheduler gets to them, and each one then finishes whatever it was in the middle of before its next cancellation check. A goroutine inside a 200 ms network call keeps going until that call returns or the transport notices the context itself.

So cancel() means “stop soon”, not “stopped”. If you need to know the work has finished, wait for it — a WaitGroup, a done channel from the workers, or errgroup.Wait. §13.7.5 shows the test that catches this.

Key Takeaways

  • Every derived context has exactly one parent; cancellation and deadlines flow down, and only a value lookup travels up
  • Cancelling a node closes Done() on its whole subtree synchronously, carrying the originating error
  • A node’s effective deadline is the earliest one anywhere on its path to the root
  • Deriving a child grows with depth, because WithCancel finds its nearest cancellable ancestor with the same walk Value uses — 237 ns at depth 0 against 1,171 ns at depth 50
  • Nothing propagates upward: a leaf cannot cancel its parent, so failures travel back as errors
  • cancel() returning means “stop soon”, never “stopped” — the goroutines wake when the scheduler reaches them
Section 13.4 — in one line

Cancellation flows down and lookups walk up, so the tree’s shape is a performance decision as much as a correctness one — and a child can only ever narrow what it inherits.

13.5 Checking for Cancellation

A context that nobody checks cancels nothing. This section is about where the checks go and what they cost.

13.5.1 The Three Ways to Check

Detection methods:
Method
<-ctx.Done() in a select
ctx.Err() != nil
select with default

The first is the one you want almost always, because it is the only one that lets a goroutine wait on cancellation and on something useful at the same time:

snippet_135.go
// Illustrative snippet — not a complete program
select {
case <-ctx.Done():
    return ctx.Err()
case job := <-jobs:
    return process(job)
}

The other two are point checks. Use them between chunks of CPU work, where there is no channel to wait on:

snippet_135_2.go
// Illustrative snippet — not a complete program
for i, item := range items {
    if i%1000 == 0 {
        if err := ctx.Err(); err != nil {
            return err
        }
    }
    process(item)
}

13.5.2 Telling Cancellation from Timeout

This is the branch that §13.2.6's table exists to get right:

snippet_135_3.go
// Illustrative snippet — not a complete program
if err := doWork(ctx); err != nil {
    switch {
    case errors.Is(err, context.Canceled):
        // the caller gave up — usually not our problem to log loudly
        return err
    case errors.Is(err, context.DeadlineExceeded):
        // we ran out of time — this is a signal about our own latency
        metrics.Timeouts.Inc()
        return err
    default:
        return fmt.Errorf("work failed: %w", err)
    }
}

Both cases must be present on every context, whatever constructor produced it. A WithCancel child under an expired WithTimeout ancestor arrives here as DeadlineExceeded; a WithTimeout context whose parent was cancelled arrives as Canceled. Writing only the Canceled case sends every ancestor timeout to default, where it is reported as a generic failure and the timeout metric never moves.

context.Cause for the richer answer. When the tree was built with WithCancelCause, Cause gives you what actually went wrong while Err() keeps its two sentinels:

snippet_135_4.go
// Illustrative snippet — not a complete program
if err := doWork(ctx); err != nil {
    if cause := context.Cause(ctx); cause != nil && cause != err {
        return fmt.Errorf("work failed (%v): %w", cause, err)
    }
    return err
}

Cause costs more than Err — §13.5.7 has the number — so call it once on the failure path, not in a loop.

13.5.3 Select Patterns

Wait for work or cancellation. The default shape:

snippet_135_5.go
// Illustrative snippet — not a complete program
for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case job, ok := <-jobs:
        if !ok {
            return nil
        }
        if err := handle(ctx, job); err != nil {
            return err
        }
    }
}

Send with cancellation. A send blocks just as a receive does, and needs the same guard:

snippet_135_6.go
// Illustrative snippet — not a complete program
select {
case <-ctx.Done():
    return ctx.Err()
case results <- value:
}

Forgetting this is the most common way a worker leaks: it finishes, tries to publish, and blocks forever on a channel nobody is reading because the consumer already returned.

First result wins. Cancel the losers as soon as one replica answers, and keep the real error when they all fail:

first_135.go
// Illustrative snippet — not a complete program
func first(ctx context.Context, hosts []string) (Result, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // stops the losers

    type outcome struct {
        res Result
        err error
    }
    ch := make(chan outcome, len(hosts))

    var wg sync.WaitGroup
    for _, h := range hosts {
        wg.Go(func() {
            r, err := query(ctx, h)
            select {
            case ch <- outcome{r, err}:
            case <-ctx.Done():
            }
        })
    }

    var lastErr error
    for range hosts {
        select {
        case o := <-ch:
            if o.err == nil {
                return o.res, nil
            }
            lastErr = o.err
        case <-ctx.Done():
            return Result{}, ctx.Err()
        }
    }
    return Result{}, fmt.Errorf("all %d hosts failed: %w",
        len(hosts), lastErr)
}

The buffered channel matters: with an unbuffered one, a loser that finishes after the winner returns would block on the send forever. The wg.Go form is Go 1.25's (§2.3) — it replaces Add(1) plus defer Done(), and the closure must not call wg.Done itself.

Returning lastErr wrapped, rather than a fresh “all hosts failed” string, is the difference between a caller that can act on the failure and one that gets a sentence.

13.5.4 Checking Inside Loops

A CPU-bound loop has no channel to wait on, so the check is a point check and the only question is how often.

At 7.30 ns for a select with default (§13.5.7), the check is nearly free relative to almost any real work:

Check frequency:
Per-iteration work
Under 100 ns
100 ns – 100 µs
Over 100 µs

The old advice to avoid checking above “10,000 iterations per second” is wrong by about three orders of magnitude. At 7.3 ns a check, ten thousand checks per second is 0.007% of one core. The threshold where it becomes worth batching is in the millions per second.

Time-based checking when iteration cost varies wildly:

next_135.go
// Illustrative snippet — not a complete program
next := time.Now().Add(10 * time.Millisecond)
for _, item := range items {
    if time.Now().After(next) {
        if err := ctx.Err(); err != nil {
            return err
        }
        next = time.Now().Add(10 * time.Millisecond)
    }
    process(item)
}

This is only worth it when the work per item is genuinely unpredictable — time.Now() costs 60.89 ns, roughly eight times a Done() check, so a fixed iteration count is cheaper whenever you can estimate one.

time.After in a loop is about allocation, not leaks

Since Go 1.23 an unreferenced timer is collected without Stop, so the old warning that time.After “leaks” a timer until it fires no longer applies (and since Go 1.27 there is no GODEBUG or go.mod setting that brings the old behaviour back). What it still does is allocate a fresh timer on every iteration, which is worth avoiding on a hot path:

t_135.go
// Illustrative snippet — not a complete program
// allocates a timer per iteration
for _, item := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(time.Second):
    }
    process(item)
}

// one timer, reset after each fire
t := time.NewTimer(time.Second)
defer t.Stop()
for _, item := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-t.C:
        process(item)
        t.Reset(time.Second)
    }
}

13.5.5 Blocking Operations

The standard library already takes contexts wherever it blocks, and using those entry points is better than any wrapper you write:

Context-aware entry points
HTTP request
http.NewRequestWithContext
SQL query
db.QueryContext, db.ExecContext
Transaction
db.BeginTx
DNS
net.Resolver.LookupHost
Dial
net.Dialer.DialContext
Sleep
a select on ctx.Done() and a timer
default_client_135.go
// Illustrative snippet — not a complete program
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
    return err
}
resp, err := http.DefaultClient.Do(req) // cancels mid-flight

For an API that predates contexts and cannot be changed, the honest wrapper returns early and lets the underlying work finish on its own:

with_context_135.go
// Illustrative snippet — not a complete program
func withContext(ctx context.Context, f func() error) error {
    done := make(chan error, 1) // buffered: f must not block
    go func() { done <- f() }()

    select {
    case err := <-done:
        return err
    case <-ctx.Done():
        return ctx.Err() // f keeps running; we stop waiting
    }
}
This does not cancel anything

It bounds your wait, not the work. The goroutine running f continues to completion, holding whatever it holds. That is acceptable for a bounded operation and unacceptable for an unbounded one — and it is why context.AfterFunc (§13.3.6), which can call the API’s own Close, is the better bridge when one exists.

13.5.6 Retrying Inside a Budget

A retry loop that ignores its context is the purest form of orphaned work: it keeps trying on behalf of a caller who left. Two things make it correct — the wait between attempts must be interruptible, and the loop must stop when the budget is gone.

with_retry_135.go
// Illustrative snippet — not a complete program
func withRetry(ctx context.Context, attempts int,
    f func(context.Context) error) error {

    var lastErr error
    backoff := 100 * time.Millisecond

    for i := 0; i < attempts; i++ {
        if i > 0 {
            t := time.NewTimer(backoff)
            select {
            case <-ctx.Done():
                // no drain needed: timer channels are synchronous
                // (Go 1.23+, unconditional since Go 1.27)
                t.Stop()
                return ctx.Err()
            case <-t.C:
            }
            backoff *= 2
        }

        lastErr = f(ctx)
        if lastErr == nil {
            return nil
        }
        // A cancelled context will not succeed on retry.
        if ctx.Err() != nil {
            return ctx.Err()
        }
    }
    return fmt.Errorf("after %d attempts: %w", attempts, lastErr)
}

The second check is the one people leave out. Without it, a context that expired during f still costs you every remaining attempt — each one starting, immediately failing on the dead context, and sleeping before the next.

Spending the budget deliberately. Deadline() lets a caller divide the time it has rather than guessing:

fetch_and_135.go
// Illustrative snippet — not a complete program
func fetchAndProcess(ctx context.Context) error {
    // Give the network call at most half of whatever is left,
    // so processing always has room.
    budget := 2 * time.Second
    if deadline, ok := ctx.Deadline(); ok {
        if half := time.Until(deadline) / 2; half < budget {
            budget = half
        }
    }

    netCtx, cancel := context.WithTimeout(ctx, budget)
    defer cancel()

    data, err := fetch(netCtx)
    if err != nil {
        return err
    }
    return process(ctx, data) // the rest of the budget
}
Do not start what you cannot finish

If time.Until(deadline) is already less than a typical call takes, the honest move is to fail immediately with ctx.Err() rather than open a connection that is certain to be abandoned. That is the one place where reading Deadline() beats simply passing the context down.

13.5.7 Performance Characteristics

Measured minimum of eight runs, one freshly constructed context per benchmark, -cpu 1.
Cost of Checking
Operation
ctx.Err(), live
ctx.Err(), cancelled
select with default
context.Cause(), live
context.Cause(), cancelled
<-ctx.Done(), blocking
time.Now()
Derived three things follow. Cancellation checks are cheap enough that skipping them is almost never the right optimisation. Cause is the expensive one — it is not an atomic read, it walks the value tree for &cancelCtxKey and then takes a mutex, so it belongs on the failure path and not in a loop. And every one of these gets more expensive after cancellation, not less, which is fine because a correct loop exits on the first positive check.

13.5.8 Common Mistakes

A loop with no cancellation check
Problem

Runs to completion after the caller gave up

Fix

Check between units of work

Checking only after the blocking call
Problem

The check happens once the damage is done

Fix

select on Done() with the operation

Busy-polling ctx.Err() in a tight spin
Problem

Burns a core to learn nothing

Fix

select on Done() and block

Guarding only the receive, not the send
Problem

The worker blocks forever publishing a result

Fix

Guard both directions

Returning a generic error on cancellation
Problem

The caller cannot tell a timeout from a failure

Fix

Return ctx.Err(), wrapped

Ignoring the context-aware API
Problem

Do cannot be interrupted; Do with a request can

Fix

http.NewRequestWithContext, QueryContext

Calling context.Cause in a loop
Problem

Ten times the cost of Err() once cancelled

Fix

Call it once, on the failure path

Summary: Checking for Cancellation

select on ctx.Done() whenever there is something else to wait on; a point check with ctx.Err() between chunks of CPU work when there is not. Guard sends as carefully as receives — a worker blocked publishing a result nobody will read is the most common context-shaped leak.

Branch on both sentinels, always. The error names the cause rather than the constructor, so a WithCancel context can hand you DeadlineExceeded and code that only tests for Canceled loses every ancestor timeout to its default branch.

The checks are cheap: 2.21 ns live, 7.30 ns for a select with default. Cause is the exception at 32.72 ns once cancelled, because it walks the value tree and takes a mutex. And since Go 1.23, time.After in a loop is an allocation problem, not a leak.

Self-Check Questions: Checking for Cancellation

Your worker selects on ctx.Done() and a jobs channel, and still hangs after cancellation. Where is it stuck?

Almost certainly on a send, not a receive.

The receive is guarded, so the worker wakes and leaves that select correctly. Then it finishes handling the job it already had and tries to publish the result — results <- value with no guard — and by then the consumer has returned, so nothing is reading. The send blocks forever.

Guard both directions:

snippet_135_7.go
// Illustrative snippet — not a complete program
select {
case results <- value:
case <-ctx.Done():
    return ctx.Err()
}

A buffered channel sized to the number of producers also works, and is what §13.5.3's first-result-wins pattern uses, because there the losers must be able to finish their sends after the winner has returned.

Is checking ctx.Err() every iteration of a tight loop too expensive?

Almost never. A live ctx.Err() is 2.21 ns and a select with default is 7.30 ns.

The old guidance to batch checks above “10,000 iterations per second” is off by roughly three orders of magnitude: ten thousand checks a second at 7.3 ns each is 0.007% of one core. The point where batching earns anything is in the millions of iterations per second, and even then the fix is to check every 1,000 iterations rather than to stop checking.

The real reason to batch is not cost but cache and branch-prediction pressure inside a genuinely hot numeric loop — and if you are in one, you already know it.

Does time.After in a select still leak a timer?

Not since Go 1.23. Timers and tickers that become unreachable are collected even if Stop was never called, so the classic advice to replace every time.After with a time.NewTimer and a defer Stop() no longer describes a leak.

What remains is allocation. time.After builds a new timer on every evaluation, so in a loop it produces one allocation per iteration and gives the collector more to do. On a hot path, hoist a single time.NewTimer out of the loop and Reset it after each fire.

The distinction matters because the two prescriptions look identical and the reasons are different: one was about a leak that no longer exists, the other is about allocation that still does.

Key Takeaways

  • select on ctx.Done() wherever there is something else to wait on; use a point check only between chunks of CPU work
  • Guard sends as carefully as receives — a worker blocked publishing a result nobody will read is the most common context-shaped leak
  • Branch on both sentinels on every context, whatever constructor produced it
  • The checks are cheap: 2.21 ns live, 7.30 ns for a select with default. The old “10,000 iterations per second” threshold is off by three orders of magnitude
  • context.Cause is the expensive one at 32.72 ns once cancelled — it walks the value tree and takes a mutex, so it belongs on the failure path
  • Since Go 1.23 time.After in a loop is an allocation problem, not a leak
  • Wrapping a context-unaware API bounds your wait, not the work — the goroutine runs to completion regardless
Section 13.5 — in one line

Check where you already block, branch on both sentinels, and stop worrying about the cost — the only expensive call here is Cause, and it belongs on the failure path.

13.6 Context Values

WithValue is the part of the package people reach for wrongly, and the package documentation says so itself: it is for request-scoped data that crosses API boundaries, and nothing else. This section is about where that line actually falls.

13.6.1 The Litmus Test

Ask one question: would this value be a parameter if every function between here and there were yours to change?

If yes, it is a parameter. Context values are for data that has to cross code you do not own — middleware, an interface you must satisfy, a handler signature fixed by a framework.

What belongs in a context
Request or trace ID
Needed for correlation at every level, interesting to none of them
Authenticated user or tenant
Established by middleware, consumed far below
Span or logger carrying request fields
Follows the request by definition
Locale, deadline hints, client IP
Properties of the request, not of the operation
What does not
Database handle, HTTP client, config
Struct fields or explicit parameters
Function arguments the callee needs
Parameters — the compiler checks those
Optional behaviour for one call
An options struct
Return values or errors
Return them
Anything mutable
See §13.6.7

The test rules out dependency injection cleanly. A *sql.DB would be a parameter if you controlled every signature, so it is a parameter.

13.6.2 The Key Collision Problem

Value compares keys with == across the whole tree, so two packages using the same key value collide silently:

ctx_136_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: any package using "user" overwrites this
ctx = context.WithValue(ctx, "user", currentUser)

A string key is shared by everyone who types the same string, and the failure is a wrong value rather than an error. Exported types are no better — another package can construct one.

The rule is an unexported key type:

user_key_136.go
// Illustrative snippet — not a complete program
type userKey struct{}

ctx = context.WithValue(ctx, userKey{}, currentUser)

No other package can name userKey, so no other package can produce a key that compares equal. A zero-size struct also costs nothing to store.

Key styles compared:
Key
"user" (string)
UserKey (exported type)
userKey struct{} (unexported)
type k int; const userKey k = 0

13.6.3 Type-Safe Accessors

Never export the key. Export a pair of functions and keep every type assertion in one place:

user_key_136_2.go
package auth

type userKey struct{}

type User struct {
    ID    string
    Email string
}

// WithUser returns a context carrying u.
func WithUser(ctx context.Context, u *User) context.Context {
    if u == nil {
        return ctx // never store nil; see §13.6.7
    }
    return context.WithValue(ctx, userKey{}, u)
}

// UserFromContext reports the user, if one was set.
func UserFromContext(ctx context.Context) (*User, bool) {
    u, ok := ctx.Value(userKey{}).(*User)
    return u, ok
}

The comma-ok form is the default because “no user” is usually a real case. Where absence is a programming error rather than a runtime condition — code that runs only behind authentication middleware — a Must variant that panics documents the invariant:

must_user_136.go
// Illustrative snippet — not a complete program
func MustUser(ctx context.Context) *User {
    u, ok := UserFromContext(ctx)
    if !ok {
        panic("auth: no user in context")
    }
    return u
}
Accessor shapes
(T, bool)
Absence is a normal case
(T, error)
Absence is a failure the caller should handle
T with a zero default
A sensible default exists — an empty trace ID
T, panicking
Absence is impossible by construction

13.6.4 How Lookup Works, and What It Costs

valueCtx stores exactly one key and one value and delegates everything else to its parent. A lookup walks up until it matches or reaches the root:

ONE NODE PER VALUE, ONE WALK PER LOOKUP

A chain of value contexts under Background: one holding a trace key, below it one holding a user key, below that one holding a tenant key, where a lookup begins. Looking up the tenant key takes one hop, the user key two hops, and the trace key three. A key that is not present walks all the way to the root before returning nil. There is no map and no index: each WithValue call adds exactly one node holding one pair.

There is no map and no index. Four WithValue calls make four nodes, and the miss case always costs the full depth.

Measured minimum of eight runs, -cpu 1, a distinct unexported zero-size struct type per key — the idiom §13.6.2 recommends. The hop figures look up the key stored first, so five hops means the full chain.
Cost of Context Values
Operation
WithValue
Value, 1 hop
Value, 5 hops
Value, miss at depth 5
Derived reading is about 1.6 ns per hop, so depth is the variable that matters, and it is the same walk that makes §13.4.5's derivation grow. A miss is not the worst case — it walks the full depth but never has to return a value, so it lands just under the deepest hit.

The 48 bytes is exactly valueCtx's size — a Context interface plus a key interface plus a value interface, three two-word headers — and it is the number to trust: it reproduced identically on three separate machines, while the nanosecond figures drifted between sessions by up to 50% because the call is allocator-bound.

The key’s type is part of the cost

At five hops the same lookup measures 11.06 ns with the zero-size struct keys this section recommends, 18.27 ns with a named integer type, and 29.93 ns with strings. Comparison happens through an interface, so a key whose type carries no data is compared by type pointer alone. The idiom is not only collision-proof, it is the fastest of the three.

Read once, then pass it down. A value read in a loop pays the walk every iteration for a value that cannot change:

trace_id_136_x_1.go
// Illustrative snippet — not a complete program
// ✗ walks the tree per item
for _, item := range items {
    log.Printf("%s: %v", TraceIDFromContext(ctx), item)
}

// ✓ one walk
traceID := TraceIDFromContext(ctx)
for _, item := range items {
    log.Printf("%s: %v", traceID, item)
}

Values that arrive together and are read together should be one node, not four:

req_info_136.go
// Illustrative snippet — not a complete program
type reqInfo struct {
    TraceID string
    User    *User
    Tenant  string
    Locale  string
}
ctx_136_x_2.go
// Illustrative snippet — not a complete program
// ✗ four nodes, four hops to reach the deepest
ctx = context.WithValue(ctx, traceKey{}, traceID)
ctx = context.WithValue(ctx, userKey{}, user)
ctx = context.WithValue(ctx, tenantKey{}, tenant)
ctx = context.WithValue(ctx, localeKey{}, locale)

// ✓ one node, one hop, one allocation
info := &reqInfo{
    TraceID: traceID,
    User:    user,
    Tenant:  tenant,
    Locale:  locale,
}
ctx = context.WithValue(ctx, reqInfoKey{}, info)

Bundle when the values are set at the same place and read at the same places. Keep them separate when they come from different layers or have different lifetimes — an auth middleware and a tracing middleware should not have to share a struct.

The bundle must be treated as immutable once stored (§13.6.7). Storing a pointer is fine; mutating what it points at is not.

13.6.6 Patterns That Earn Their Place

Request ID propagation, the canonical case — generated once at the edge, read anywhere:

request_idmiddleware_136.go
// Illustrative snippet — not a complete program
func RequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter,
        r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.New().String()
        }
        ctx := WithRequestID(r.Context(), id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

uuid here is Go 1.27’s standard-library package (NewV7 if you want time-sortable IDs) — no third-party module needed.

r.WithContext(ctx) is the required step: it returns a copy of the request carrying the new context. Mutating r in place is not an option, and forgetting the copy means the value never reaches the handler.

Distributed tracing, which is the case the package documentation had in mind. A span is created at the edge and every layer below adds to the same trace without any of them taking a span parameter:

handler_136.go
// Illustrative snippet — not a complete program
func Handler(w http.ResponseWriter, r *http.Request) {
    ctx, span := tracer.Start(r.Context(), "handler")
    defer span.End()

    // db.Query starts a child span from ctx; the signature
    // says nothing about tracing
    if err := db.Query(ctx, sql); err != nil {
        span.RecordError(err)
    }
}

This passes the litmus test cleanly: the span identifies this request, every layer needs it, and no layer’s signature should mention it. Note that tracer.Start returns a new context — forgetting to reassign it is how a trace silently loses half its spans.

A request-scoped logger, which is the same pattern with the fields pre-bound:

logger_from_136.go
// Illustrative snippet — not a complete program
func LoggerFromContext(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
        return l
    }
    return slog.Default() // always usable, never nil
}

Returning a working default rather than nil is what makes this safe to call from anywhere, including code paths that run outside a request.

13.6.7 Common Mistakes

A string or exported key
Problem

Any package using the same value collides silently

Fix

Unexported zero-size struct type

Exporting the key
Problem

Callers assert types by hand, everywhere

Fix

Export WithX/XFromContext instead

Passing dependencies through context
Problem

Invisible to the compiler; fails at run time

Fix

Struct fields or parameters

Storing something mutable
Problem

Every holder shares it, with no lock

Fix

Store immutable values, or a pointer nobody writes

Storing nil
Problem

Indistinguishable from absent

Fix

Return the parent unchanged instead

An unchecked type assertion
Problem

Panics when the value is missing

Fix

Comma-ok, always

Reading in a loop
Problem

Pays the tree walk per iteration

Fix

Read once, pass it down

One WithValue per middleware
Problem

Deepens every lookup and every derivation

Fix

Bundle values set together

Using context for optional parameters
Problem

Callers cannot discover them

Fix

An options struct

Mutable state is the one that produces the strangest bugs:

ctx_136_x_3.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: every holder of ctx shares this map, unsynchronised
ctx = context.WithValue(ctx, tagsKey{}, map[string]string{})

// ✓ store what is already immutable
ctx = context.WithValue(ctx, tagsKey{}, tags) // a value copy

The map is shared by every goroutine that receives the context, and nothing in context synchronises it. This is a data race and -race will find it — the only bug in this chapter that it does.

Summary: Context Values

WithValue is for request-scoped data crossing code you do not own. The litmus test — would this be a parameter if you controlled every signature? — settles almost every case, and rules out dependency injection immediately.

Keys must be an unexported type so no other package can construct one, and the key should never be exported: export WithX and XFromContext and keep the assertion in one place.

Lookup is a walk up a chain of one-value nodes, about 1.6 ns per hop, with no map and no index. WithValue allocates exactly 48 bytes and one object. So depth is the cost that matters: bundle values that arrive together, read once outside loops, and remember that the same walk is what makes deriving a child O(depth).

Self-Check Questions: Context Values

Why is context.WithValue(ctx, "userID", id) wrong even when it works?

Because keys are compared with == across the entire tree, and a string key is shared by everyone who types the same characters.

Any other package — a library, a middleware you vendored, a future version of your own code — that stores under "userID" writes into the same slot. Closer nodes shadow further ones, so the failure is a wrong value rather than an error or a panic. Nothing warns you, and the bug appears only when both packages are on the same request path.

An unexported key type fixes it structurally: type userKey struct{} cannot be named outside its package, so no foreign key can ever compare equal. It is also zero-size, so it costs nothing to store.

Four middlewares each add one value with WithValue. What has that cost you?

Four nodes on the chain, and the cost lands in two places.

Lookups get longer: Value walks from the current node toward the root, about 1.6 ns per hop, and a miss walks the full depth. Deriving contexts also gets slower, because WithCancel finds its nearest cancellable ancestor with the same walk — §13.4.5 measured 237 ns at depth 0 against 1,171 ns at depth 50.

Allocation is 48 bytes and one object per WithValue, so four nodes is 192 bytes per request.

At four levels none of this is a problem. It becomes one when middleware layers accumulate and something derives a context per item in a loop. The fix is to bundle values that are set together and read together into a single immutable struct: one node, one hop, one allocation.

You store a map[string]string in a context so handlers can add tags. What is wrong?

Every goroutine holding that context holds the same map, and context synchronises nothing. Concurrent writes, or a write concurrent with a read, are a genuine data race.

It is also the one bug in this chapter that -race will actually catch. Every other failure here — orphaned work, a swallowed timeout, a missing cancel(), a double Close from AfterFunc — is a race condition rather than a data race, invisible to the detector for the reason §8.3 gives. A mutable map in a context is the exception, because it is an ordinary unsynchronised shared write.

The deeper problem is the design: context values are for describing what a request is, and that description does not change halfway through. If handlers need to accumulate something, return it or write to a struct the caller owns.

Key Takeaways

  • The litmus test: would this be a parameter if you controlled every signature? If yes, it is a parameter
  • Keys must be an unexported type, so no other package can construct one that compares equal; a string key is shared by everyone who types it
  • Never export the key — export WithX and XFromContext and keep the assertion in one place
  • Lookup is a walk up a chain of single-value nodes at about 1.6 ns per hop, with no map and no index
  • The key’s type is part of the cost: zero-size struct keys are both collision-proof and the fastest option
  • WithValue allocates exactly 48 bytes and one object, so depth is the variable that matters — bundle values that arrive and are read together
  • Anything mutable in a context is shared by every holder with no synchronisation, and it is the one bug here -race will catch
Section 13.6 — in one line

Context values are for things a request is, never for things a function needs — and every one you add is another node on every lookup and every derivation beneath it.

13.7 Context in Practice

The rules in this section are conventions rather than compiler-enforced requirements, which is exactly why they are worth stating: nothing will stop you breaking them, and the breakage shows up in production.

13.7.1 The First-Parameter Convention

Context goes first, is named ctx, and is never optional:

store_137.go
// Illustrative snippet — not a complete program
func Fetch(ctx context.Context, url string) (*Response, error)
func (s *Store) Get(ctx context.Context, id string) (*Item, error)

The convention is worth more than its aesthetics. A uniform position makes “is this cancellable?” answerable at a glance across a whole codebase, it makes the mechanical addition of contexts to an existing API a predictable edit, and it means a wrapper can forward ctx without thinking. The standard library follows it without exception.

Accept a context when the function does I/O, blocks, spawns goroutines, may run long enough to be worth abandoning, or calls anything that does. Do not accept one for pure computation, constructors that do no work, getters and setters, or String() — a context there is noise that suggests a cancellation point which does not exist.

Never make it optional

A nil context panics on the first method call, and a variadic ctx ...context.Context invites exactly that. If a caller genuinely has no context yet, context.TODO() is the honest placeholder.

13.7.2 Never Store a Context in a Struct

client_137_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: one context, many calls, unrelated lifetimes
type Client struct {
    ctx  context.Context
    http *http.Client
}

func (c *Client) Get(url string) error {
    return do(c.ctx, url) // whose deadline is this?
}

A context describes the lifetime of one operation. A struct usually outlives many, so a stored context is either stale — cancelled by the first request that finished, breaking every later call — or immortal, silently discarding every caller’s deadline. Neither is discoverable from the call site, because the signature no longer mentions a context at all.

client_137_2.go
// Illustrative snippet — not a complete program
// ✓ the caller's scope reaches the work
type Client struct {
    http *http.Client
}

func (c *Client) Get(ctx context.Context, url string) error {
    return do(ctx, url)
}

The one exception is a type whose whole purpose is a lifetime — a server, a worker pool, a subscription. There the stored context is the object’s lifetime, and the honest form stores the cancel function too:

worker_137.go
// Illustrative snippet — not a complete program
type Worker struct {
    ctx    context.Context
    cancel context.CancelFunc
    wg     sync.WaitGroup
}

func NewWorker(parent context.Context) *Worker {
    ctx, cancel := context.WithCancel(parent)
    return &Worker{ctx: ctx, cancel: cancel}
}

func (w *Worker) Stop() {
    w.cancel()
    w.wg.Wait() // cancel means "stop soon"; this means "stopped"
}

Note that per-operation methods on such a type still take their own ctx. The stored one governs the worker’s life, not each request’s.

13.7.3 Designing Context-Aware APIs

Four rules
Accept a context; do not create one
Creating one severs the caller’s scope (§13.4.6)
Context in methods, not constructors
A constructor’s context outlives the call that made it
No timeout parameter beside a context
Two sources of truth; the caller already has one
Return ctx.Err() on cancellation
The caller must be able to tell why

The duplicate-timeout rule catches people mid-migration:

query_137_x_1.go
// Illustrative snippet — not a complete program
// ✗ which one wins?
func Query(ctx context.Context, sql string, timeout time.Duration) error

// ✓ the caller expresses the bound in the context
func Query(ctx context.Context, sql string) error

If callers need a per-call bound, they have one: context.WithTimeout before the call. A second parameter can only agree with the context or contradict it.

Adding a context to an existing API without breaking callers is a two-signature move — keep the old name delegating to a new one:

fetch_137.go
// Illustrative snippet — not a complete program
func Fetch(url string) (*Response, error) {
    return FetchContext(context.Background(), url)
}

func FetchContext(ctx context.Context, url string) (*Response, error) {
    // real implementation
}

That is what the standard library did for database/sql and net/http, and the Context suffix is the recognised signal.

13.7.4 Propagation Across Boundaries

In HTTP handlers, the request already carries a context that is cancelled when the client disconnects:

handler_137.go
// Illustrative snippet — not a complete program
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // cancelled when the client goes away

    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    if err := doWork(ctx); err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, "error", http.StatusInternalServerError)
        return
    }
}
The request context dies when the handler returns

Work started with it and left running is cancelled the moment you respond, and r.Body is closed too. This is the mistake worth naming, because the usual fix makes it worse:

bg_137_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: cancelled the instant the handler returns
go audit(r.Context(), event)

// ✗ ALSO BROKEN: survives, but drops the trace ID and every other value
go audit(context.Background(), event)

// ✓ keeps the values, drops the cancellation, sets a fresh bound
bg := context.WithoutCancel(r.Context())
ctx, cancel := context.WithTimeout(bg, 30*time.Second)
go func() {
    defer cancel()
    audit(ctx, event)
}()

If the background work needs the body, read it before responding — r.Body is not valid after the handler returns, whatever context you hand the goroutine.

Through a database, where the context bounds the query and the transaction both:

snippet_137.go
// Illustrative snippet — not a complete program
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback() // no-op once Commit succeeds

if _, err := tx.ExecContext(ctx, q, args...); err != nil {
    return err
}
return tx.Commit()

Cancelling ctx here rolls the transaction back — database/sql watches the context for the life of the Tx. That is a good default and a sharp edge: a transaction started from a request context dies with the request, which is correct for a query and wrong for a commit you wanted to finish. If it must complete, it needs §13.3.5's detachment and a bound of its own.

Across a network boundary, the context does not travel — only its values can, and only if something serialises them. gRPC turns metadata into context values on the way in and back out; HTTP needs you to do it by hand, which is what the X-Request-ID header in §13.6.6 is doing. Deadlines are the exception worth knowing: gRPC propagates them, so a server sees a deadline derived from its caller’s, while a plain HTTP call does not unless you send one explicitly.

Into goroutines, always pass the context explicitly rather than capturing an outer one, so each goroutine’s scope is visible at the call site:

wg_137.go
// Illustrative snippet — not a complete program
var wg sync.WaitGroup
for _, shard := range shards {
    wg.Go(func() {
        // a per-shard bound under the caller's budget
        c, cancel := context.WithTimeout(ctx, 2*time.Second)
        defer cancel()
        query(c, shard)
    })
}
wg.Wait()

13.7.5 Testing with Context

Since Go 1.24, t.Context() gives a test a context that is cancelled just before cleanup runs — the right default, and often miscited as a 1.21 feature:

fetch_test_137_2.go
// Illustrative snippet — not a complete program
func TestFetch(t *testing.T) {
    ctx := t.Context() // Go 1.24; cancelled before Cleanup
    if _, err := Fetch(ctx, testURL); err != nil {
        t.Fatal(err)
    }
}

Test that cancellation is honoured, which means asserting the work actually stopped rather than that cancel() returned:

worker_stops_test_137.go
// Illustrative snippet — not a complete program
func TestWorkerStopsOnCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(t.Context())
    stopped := make(chan struct{})

    go func() {
        defer close(stopped)
        Worker(ctx)
    }()

    cancel()

    select {
    case <-stopped:
    case <-time.After(time.Second):
        t.Fatal("worker still running 1s after cancel")
    }
}

The timeout arm is the test. Without it the goroutine leaks and the test passes anyway — the failure mode §2.5 describes, reproduced in a test suite.

Test the timeout path with an already-expired context, which needs no sleeping:

err_137.go
// Illustrative snippet — not a complete program
ctx, cancel := context.WithTimeout(t.Context(), 0)
defer cancel()

err := Fetch(ctx, testURL)
if !errors.Is(err, context.DeadlineExceeded) {
    t.Fatalf("got %v, want DeadlineExceeded", err)
}

13.7.6 Common Mistakes

Context stored in a struct field
Problem

One lifetime shared by unrelated calls

Fix

Pass it per method

Context somewhere other than first
Problem

Breaks the convention every tool assumes

Fix

First parameter, named ctx

Ignoring the returned context
Problem

WithValue/WithTimeout return a new one

Fix

Reassign: ctx, cancel := ...

Shadowing ctx in an inner scope
Problem

The outer scope silently keeps the old one

Fix

Name the derived one, or reassign deliberately

Using r.Context() after responding
Problem

Cancelled on return; r.Body is closed

Fix

WithoutCancel plus a new bound

context.Background() for background work
Problem

Drops trace IDs and every other value

Fix

context.WithoutCancel(ctx)

A timeout parameter beside a context
Problem

Two sources of truth

Fix

Let the caller bound it

cancel() and assuming it stopped
Problem

Closes channels; goroutines wake later

Fix

Wait on a WaitGroup or a done channel

context.TODO() in shipped code
Problem

Nothing can cancel that subtree

Fix

Wire the real context

Summary: Context in Practice

Context is the first parameter, named ctx, never optional, never stored in a struct — with the single exception of a type whose purpose is a lifetime, where the cancel function belongs beside it and Stop waits for the work to end.

Accept a context, never create one mid-tree; put it on methods rather than constructors; never pair it with a timeout parameter. Return ctx.Err() so callers can tell a cancellation from a failure.

Across boundaries, r.Context() is cancelled the moment the handler returns, so background work needs WithoutCancel plus a fresh bound — not Background(), which throws away the trace ID that makes the work findable. And in tests, t.Context() (Go 1.24) is the right default, with the real assertion being that the work stopped, not that cancel() returned.

Self-Check Questions: Context in Practice

Why is storing a context in a struct field wrong, and what is the exception?

A context describes the lifetime of one operation; a struct usually serves many. A stored context is therefore either stale — cancelled by whichever call finished first, so every later call fails immediately — or immortal, silently discarding every caller’s deadline. Worse, the method signature no longer mentions a context, so nothing at the call site suggests the operation can be cancelled at all.

The exception is a type whose entire purpose is a lifetime: a server, a worker pool, a subscription. There the stored context is the object’s life, and it should be stored together with its cancel so that Stop() can call cancel() and then wait for the work to finish. Per-operation methods on such a type still take their own ctx.

A handler starts go audit(r.Context(), event) and the audit records stop appearing. Why, and what is the right fix?

r.Context() is cancelled the moment the handler returns, which is almost immediately. The goroutine starts, the handler responds, the context is cancelled, and the audit call aborts — usually before it has done anything.

The obvious fix, context.Background(), works and is still wrong: it drops every value in the tree, so the audit record loses the trace ID and request ID that would let anyone correlate it later.

The right form keeps the values, drops the cancellation, and sets a fresh bound:

bg_137_2.go
// Illustrative snippet — not a complete program
bg := context.WithoutCancel(r.Context())
ctx, cancel := context.WithTimeout(bg, 30*time.Second)
go func() { defer cancel(); audit(ctx, event) }()

The new timeout matters as much as the detach — detached work with no deadline is how a background goroutine outlives the process it was reporting on. And if the audit needs the request body, read it before responding: r.Body is closed when the handler returns regardless of which context you pass.

Your test calls cancel() and then asserts the worker exited. What makes that test trustworthy?

A timeout arm. cancel() closes Done() synchronously, but it does not run the worker’s code — the worker wakes when the scheduler reaches it and after it finishes whatever it was doing. So a test that calls cancel() and immediately checks a flag is testing the scheduler, not the worker.

Close a channel when the worker returns, then select on that channel against time.After:

snippet_137_2.go
// Illustrative snippet — not a complete program
select {
case <-stopped:
case <-time.After(time.Second):
    t.Fatal("worker still running 1s after cancel")
}

Without the timeout arm the test blocks forever on a broken worker, or — if written as a bare flag check — passes while the goroutine leaks. That is §2.5's failure reproduced inside a test suite, which is the one place it should be impossible.

Key Takeaways

  • Context is the first parameter, named ctx, never optional, never nil
  • Never store it in a struct — the exception is a type whose purpose is a lifetime, where cancel belongs beside it and Stop waits for the work to end
  • Accept a context, never create one mid-tree; deriving from Background() silently discards the caller’s deadline
  • Never pair a context with a timeout parameter — the caller already has a way to express the bound
  • r.Context() dies when the handler returns, and so does r.Body; background work needs WithoutCancel plus a fresh bound, not Background()
  • t.Context() (Go 1.24) is the right context for a test, and the real assertion is that the work stopped, not that cancel() returned
Section 13.7 — in one line

Pass it, never store it, never create one you were supposed to inherit — and remember that cancel() means “stop soon”, so anything that must be finished has to be waited for.

Chapter Summary

context.Context is a done channel with a tree, a clock and a reason attached. Four read-only methods, one irreversible transition, and one rule that explains most of the rest: cancellation and deadlines travel down, and the only thing that travels up is a value lookup.

Done() is close-as-broadcast from §3.3, chosen over a flag or a callback because it is the only form that composes with select. Err() reports one of two sentinels and reports the error that ended the tree, not the constructor you called — which is why a WithCancel child can hand you DeadlineExceeded, and why every branch on these errors must handle both.

Derivation is how you build scope. WithCancel for a stop you own, WithTimeout/WithDeadline for one the clock owns, and a child’s deadline is always the earlier of its own and its parent’s — narrowing allowed, widening impossible, enforced by the constructor. Since Go 1.20 a cancellation can carry a real cause; since 1.21 you can detach from cancellation while keeping values, and register cleanup that runs when a context ends.

The costs are small and they are not flat. A live ctx.Err() is 2.21 ns and a cancelled one is 19.38 ns, because the non-nil path orders the error against the channel close. A value lookup is about 1.6 ns per hop, and deriving a child grows with depth for the same reason — both walk the chain. Every WithValue is 48 bytes and one more node on every lookup beneath it.

And cancel() releases a map entry and a timer, not a goroutine. The constructors start no goroutines at all under a standard parent. Skipping the call costs 115 bytes per orphaned child, held for as long as the parent lives — a memory leak with an ordinary profile signature, invisible to the goroutine dump everyone reaches for first.

Chapter Connections

How Chapter 13 Connects
Chapter 2
§2.4's “how does this goroutine exit?” is the question context answers at the scale of a whole request tree
Chapter 3
Done() is §3.3's close-as-broadcast; §13.2.2 is why a channel beat a flag and a callback
Chapter 4
Context is §4.3's done channel with a parent edge; §13.1.2 is where the hand-wired version runs out
Chapter 5
§13.5.3's first-result-wins needs a buffered channel for exactly §5.2's reason
Chapter 7
Fan-out is the shape context was designed for — one scope per stage, cancelled together
Chapter 8
§8.3's data race versus race condition: every bug here is the second kind, except the mutable map in §13.6.7
Chapter 10
§10.4's goroutine dump is the wrong tool for a missing cancel() — §13.3.7 shows why the heap is the right one
Chapter 11
Err()'s fast path is an atomic load, and §13.2.3's store-then-close is §11.3's ordering problem in miniature
Chapter 12
§12.4 says sync.Cond cannot time out; a context is the answer, and §13.3.6's AfterFunc is the bridge
Chapter 14
errgroup gives a group one context and cancels it on the first error — §13.4.7's “nothing propagates upward”, packaged

Final Checklist

Before moving to Chapter 14, ensure you can:

Exercise 13.1 — Leave Cleanly, and Say Why

Your move

Leave Cleanly, and Say Why

This fan-out search is correct in every way the earlier chapters taught you to check. It compiles. go vet is happy. go test -race reports nothing at all, because every value it touches is either local or a channel — there is no data race here to find.

It still leaks two goroutines per call, and it lies about why it stopped.

The leak is a send. Each backend runs in its own goroutine and publishes to an unbuffered channel; when one backend wins, Search returns and stops reading. The losers are cancelled, return promptly, and then park forever trying to hand back a result nobody wants. §13.5.3's rule, turned around: the receive was guarded and the send was not.

The lie is §13.2.6's row. Search derives its own child with WithCancel, and the author reasoned that anything arriving on sub.Done() must therefore be a cancellation. It is not. A context reports the error that ended the tree, so when the caller’s deadline expires, this child’s Err() is DeadlineExceeded — and a caller trying to distinguish “I gave up” from “we ran out of time” is told the wrong one.

ch13/search.go
// Package ch13 is the exercise for Chapter 13: Context.
//
// Search queries several backends at once and returns the first
// successful answer. It is meant to hold four promises:
//
//   - return as soon as any backend succeeds
//   - stop the other backends once one has
//   - report the context's own error when the context ends first
//   - leave no backend still running when it returns
//
// TODO(reader): this type compiles, vets clean, and has no data
// race. It breaks the last two promises. Two tests prove it. Each
// needs a different fix, and neither fix repairs the other.
package ch13

import (
	"context"
	"errors"
	"fmt"
)

// A Backend answers a query, or fails trying.
type Backend func(ctx context.Context, query string) (string, error)

// ErrNoBackends is returned when there is nothing to query.
var ErrNoBackends = errors.New("search: no backends")

type result struct {
	value string
	err   error
}

// Search runs every backend against query and returns the first
// successful result. If every backend fails it returns the last
// error. If ctx ends before any backend answers, it reports that.
func Search(ctx context.Context, query string,
	backends []Backend) (string, error) {

	if len(backends) == 0 {
		return "", ErrNoBackends
	}

	// Our own scope, so returning early stops the losers.
	sub, cancel := context.WithCancel(ctx)
	defer cancel()

	ch := make(chan result)

	for _, b := range backends {
		go func() {
			v, err := b(sub, query)
			ch <- result{value: v, err: err}
		}()
	}

	var lastErr error
	for i := 0; i < len(backends); i++ {
		select {
		case r := <-ch:
			if r.err == nil {
				return r.value, nil
			}
			lastErr = r.err

		case <-sub.Done():
			// sub came from WithCancel, so reaching here
			// means somebody called cancel.
			return "", context.Canceled
		}
	}

	return "", fmt.Errorf("all %d backends failed: %w",
		len(backends), lastErr)
}

The tests:

ch13/search_test.go
package ch13

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

// waitForGoroutines polls until the count drops back to want, or
// the budget runs out. A goroutine parked forever on a send never
// goes away, so this reports the real number on failure.
func waitForGoroutines(t *testing.T, want int, d time.Duration) int {
	t.Helper()
	deadline := time.Now().Add(d)
	got := runtime.NumGoroutine()
	for time.Now().Before(deadline) {
		if got <= want {
			return got
		}
		time.Sleep(5 * time.Millisecond)
		got = runtime.NumGoroutine()
	}
	return got
}

// Gate 1. Search returns as soon as one backend wins. The other
// two are then cancelled and return. Nothing they do afterwards
// may keep a goroutine alive.
func TestSearchLeavesNoBackendRunning(t *testing.T) {
	winner := func(ctx context.Context, q string) (string, error) {
		return "found", nil
	}
	loser := func(ctx context.Context, q string) (string, error) {
		<-ctx.Done()
		return "", ctx.Err()
	}

	base := runtime.NumGoroutine()

	got, err := Search(t.Context(), "q",
		[]Backend{winner, loser, loser})
	if err != nil || got != "found" {
		t.Fatalf("Search = %q, %v; want \"found\", nil", got, err)
	}

	if n := waitForGoroutines(t, base, 2*time.Second); n > base {
		t.Fatalf("%d goroutines still running 2s after Search "+
			"returned (started from %d).\n\n"+
			"The two losing backends were cancelled and did "+
			"return. Their goroutines then tried to publish a "+
			"result to a channel Search had already stopped "+
			"reading, and blocked there forever.\n\n"+
			"A send blocks exactly like a receive. Guard it.",
			n, base)
	}
}

// Gate 2. The parent's deadline expires while every backend is
// still busy. Search must report why it stopped.
func TestSearchReportsWhyItStopped(t *testing.T) {
	block := make(chan struct{})
	t.Cleanup(func() { close(block) })

	busy := func(ctx context.Context, q string) (string, error) {
		<-block
		return "", nil
	}

	ctx, cancel := context.WithTimeout(t.Context(),
		20*time.Millisecond)
	defer cancel()

	_, err := Search(ctx, "q", []Backend{busy, busy})

	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("Search returned %v; want DeadlineExceeded.\n\n"+
			"The deadline belonged to the caller's context, and "+
			"Search derived its own child with WithCancel. The "+
			"child reports the error that ended the tree, not "+
			"the constructor that made it -- so this child's "+
			"Err() is DeadlineExceeded, not Canceled.\n\n"+
			"Report the error the context actually has.", err)
	}
}

// Guard. A fix that reports the context's error unconditionally
// would satisfy gate 2 and make Search useless.
func TestSearchReturnsFirstSuccess(t *testing.T) {
	errDown := errors.New("backend down")
	fail := func(ctx context.Context, q string) (string, error) {
		return "", errDown
	}
	slow := func(ctx context.Context, q string) (string, error) {
		select {
		case <-time.After(10 * time.Millisecond):
			return "late but good", nil
		case <-ctx.Done():
			return "", ctx.Err()
		}
	}

	got, err := Search(t.Context(), "q",
		[]Backend{fail, fail, slow})
	if err != nil {
		t.Fatalf("Search returned %v; want the slow success", err)
	}
	if got != "late but good" {
		t.Fatalf("Search = %q; want %q", got, "late but good")
	}

	// And when everything fails, the real error survives.
	_, err = Search(t.Context(), "q", []Backend{fail, fail})
	if !errors.Is(err, errDown) {
		t.Fatalf("Search returned %v; want it to wrap the "+
			"backend's own error", err)
	}
}

Run it:

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

It fails twice:

Terminal
--- FAIL: TestSearchLeavesNoBackendRunning (2.01s)
    search_test.go:49: 4 goroutines still running 2s after
        Search returned (started from 2).
        The two losing backends were cancelled and did
        return. Their goroutines then tried to publish a
        result to a channel Search had already stopped
        reading, and blocked there forever.
        A send blocks exactly like a receive. Guard it.
--- FAIL: TestSearchReportsWhyItStopped (0.02s)
    search_test.go:78: Search returned context canceled;
        want DeadlineExceeded.
        The deadline belonged to the caller's context, and
        Search derived its own child with WithCancel. The
        child reports the error that ended the tree, not
        the constructor that made it -- so this child's
        Err() is DeadlineExceeded, not Canceled.
        Report the error the context actually has.
FAIL
FAIL corebackend.dev/go-concurrency/ch13 2.449s
FAIL

The goroutine numbers are stable — ten runs on the reference machine gave 4 and 2 every time — because a goroutine parked on a send never goes anywhere. On Go 1.27 you can also ask the runtime directly: pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1) after Search returns lists exactly those two goroutines, because the channel they are sending on is no longer reachable from anything that could receive (§19.3.6). The test keeps counting by hand so the mechanism stays visible.

Done when: go test -race ./... in code/ch13/ reports ok for all three tests, and keeps reporting it under -count=10. TestSearchReturnsFirstSuccess is there to stop the fix that breaks ordinary use — reporting ctx.Err() whenever it is non-nil would satisfy the second gate and make Search useless.
Two traps: the two failures need two different changes, and each one alone leaves the other failing. Guarding the send stops the leak and Search still reports Canceled for a deadline. Reporting sub.Err() tells the truth and still leaks two goroutines per call. You need both.

There is also more than one right answer to the first one. A select on the send is shown in the worked solution because it keeps working when the number of producers is not known in advance; buffering the channel to len(backends) is equally correct here and worth understanding as the alternative.

Where the files are: labs/go-concurrency/code/ch13/. A worked answer sits in solution/search.go.txt, including why context.Cause would be the better choice if the tree had been built with WithCancelCause.

Further Reading

Next

You can now reach for an assembled primitive and say what it promises rather than what its name suggests. Every one of them, though, coordinates goroutines that are already running and protects state that sits still. Nothing so far has been able to tell an entire tree of goroutines that the work they are doing no longer matters. Chapter 13 is that mechanism: context.Context, the done channel from Chapter 4 with a parent edge, a deadline and a reason attached — and the measured truth about what defer cancel() really releases.