Context cancellation patterns I keep reaching for
context.Context is one of those APIs that looks trivial and takes a while to use well. The signature is tiny; the discipline around it is the whole thing. Here are the three patterns I reach for again and again, and the one mistake that cost me a production incident.
Pass it, don't store it
The first rule, straight from the docs and worth repeating: a Context is a per-call value. It's the first parameter of a function, conventionally named ctx, and it is never stored in a struct field. The reason is that its lifetime is the lifetime of one request or operation; stash it in a struct and you've tied a long-lived object to a short-lived cancellation, which is how you get a cancelled context used for work that should still be running, or the reverse.
func (s *Service) Fetch(ctx context.Context, id string) (*Row, error) {
// ctx flows down into everything this call touches
return s.db.QueryRow(ctx, id)
}
Deadlines, and the deferred cancel
The most common thing I actually want is "give up after N seconds." context.WithTimeout does it, and the defer cancel() is not optional:
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
row, err := s.Fetch(ctx, id)
Even when the operation finishes well before the timeout, you must call cancel. If you don't, the timer and the associated resources aren't released until the deadline fires. go vet will warn about the missing cancel, and I've turned that into a CI failure rather than a warning — it's a leak every time.
errgroup for fan-out
When I need to run several things concurrently and stop them all if one fails, I used to hand-roll a sync.WaitGroup plus an error channel plus a cancel. golang.org/x/sync/errgroup packages exactly that, and its WithContext cancels the shared context the moment any goroutine returns an error:
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url // capture before Go 1.22; harmless after
g.Go(func() error {
return fetch(ctx, url)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("fetch batch: %w", err)
}
The nice property: if one fetch fails, ctx is cancelled, and every other fetch that respects the context unwinds instead of running to completion for results you're going to throw away. g.Wait() returns the first error.
The mistake: ignoring ctx.Done()
Cancellation in Go is cooperative. Passing a context that gets cancelled does nothing unless the code at the bottom actually checks it. The incident I mentioned was a worker that read from a channel in a loop and never once consulted ctx.Done():
// leaks: nothing here stops when ctx is cancelled
for item := range queue {
process(item)
}
When the parent cancelled — on shutdown, or on a failed sibling — this goroutine kept running. During a deploy that drained the queue slowly, hundreds of these piled up across restarts. The fix is to make the loop honour cancellation:
for {
select {
case <-ctx.Done():
return ctx.Err()
case item, ok := <-queue:
if !ok {
return nil
}
process(item)
}
}
Most of the standard library and any decent database or HTTP library already checks the context for you — that's why passing it through matters. It's your own loops, especially the ones reading from channels, where you have to add the check by hand. The rule I settled on: any for loop that can run unboundedly should have a ctx.Done() case, or a very good reason it doesn't.
None of these are advanced. But "pass it down, always defer cancel, and make your own loops check Done" covers the large majority of the context code I write, and the discipline is the part that doesn't come from reading the signature.