{} The Go Reference

Concurrency pattern · Intermediate

Context & Cancellation

Propagate cancellation, deadlines, and request-scoped values across API boundaries and goroutine trees with context.Context.

Concurrency Intermediate ⏱ 3 min read Complete

📢 Analogy

A company issues a “stop work” order from the top. It cascades down the org chart — every department and team that was listening hears it and halts. Nobody keeps working on a cancelled project. context.Context is that order, flowing down a tree of goroutines.

The problem

A request spawns goroutines, which spawn more. When the request is cancelled — the client hung up, a deadline passed, a sibling failed — all of that work should stop, promptly, without leaking goroutines. Threading a bespoke done channel through every function is tedious. context.Context standardizes it: one value carries cancellation, deadlines, and request-scoped data down the whole tree.

Structure

graph TD
B["context.Background()"] --> T["WithTimeout(100ms)"]
T --> W1["worker 1<br/>select ctx.Done()"]
T --> W2["worker 2<br/>select ctx.Done()"]
T -. "deadline or cancel()" .-> W1
T -. closes Done() .-> W2

Idiomatic Go

ctx is the first parameter; workers select on ctx.Done(). A timeout cancels the whole tree automatically. Edit and Run:

context.go — editable & runnable
package main

import (
"context"
"fmt"
"time"
)

// worker runs until its context is cancelled or the deadline passes.
func worker(ctx context.Context, id int) {
for {
	select {
	case <-ctx.Done(): // cancelled or timed out
		fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
		return
	case <-time.After(30 * time.Millisecond):
		fmt.Printf("worker %d tick\n", id)
	}
}
}

func main() {
// cancel the whole tree after 100ms
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() // always release the context's resources

go worker(ctx, 1)
go worker(ctx, 2)

<-ctx.Done()                      // wait for the deadline
time.Sleep(20 * time.Millisecond) // give workers a moment to print
fmt.Println("main done:", ctx.Err())
}

🐹 Context is the modern 'done' channel

Earlier patterns used a hand-rolled done channel (Pipeline, Generator). In real code, ctx.Done() plays that role — but it also carries deadlines and values, and it composes: cancel a parent and every derived context cancels too. The conventions: pass ctx as the first argument, never store it in a struct, always defer cancel(), and use ctx.Value sparingly for genuinely request-scoped data.

The four constructors

Every context derives from a parent, forming a tree where cancelling a node cancels its whole subtree:

ConstructorGives youUse for
Background() / TODO()the empty root contexttop of main/request; TODO = “not wired yet”
WithCancel(parent)a ctx + manual cancel()stop a goroutine tree on demand
WithTimeout / WithDeadlineauto-cancel after a duration / at a timebound requests, queries, RPCs
WithValue(parent, k, v)a ctx carrying one valuerequest-scoped data (trace ID, auth)

Pass ctx as the first parameter (func F(ctx context.Context, …)), never store it in a struct, and defer cancel() the moment you create a cancellable one. The concurrency track’s context page covers the propagation rules in depth.

In the standard library

  • http.Request.Context() — cancelled when the client disconnects.
  • database/sql QueryContext, ExecContext — abort slow queries.
  • os/signal.NotifyContext — a context cancelled on SIGINT/SIGTERM for graceful shutdown.

Pitfalls

⚠️ A forgotten cancel() is a leak

WithCancel and WithTimeout start internal bookkeeping (and a timer). If you never call the returned cancel, that lives until the parent context is cancelled — a slow leak the linter (go vet) will warn about. Write defer cancel() on the line after you create the context, every time.

When to use it — and when not

✅ Reach for it when

  • You need to cancel a whole tree of goroutines from one place.
  • You want timeouts or deadlines on operations (requests, queries, RPCs).
  • A value (request ID, auth) must travel with a call across packages.

⛔ Think twice when

  • Storing a context in a struct for later — pass it as the first argument instead.
  • Using ctx.Value as a general-purpose parameter bag.
  • Purely local code with no cancellation or boundary crossing.

Check your understanding

Score: 0 / 5

1. What is context.Context primarily for?

Context is the standard way to signal cancellation, enforce deadlines, and pass request-scoped data down a call tree.

2. How does a goroutine react to cancellation?

ctx.Done() returns a channel that closes on cancellation or deadline; goroutines select on it and return, then ctx.Err() says why.

3. What must you always do with the cancel func from WithCancel/WithTimeout?

Not calling cancel leaks the context's timer/goroutine until the parent is cancelled. `defer cancel()` right after creation is the rule.

4. Which constructor gives a context that auto-cancels after a duration?

WithTimeout/WithDeadline return a context that's cancelled when the timer fires or you call cancel, whichever comes first. WithCancel cancels only manually; Background is the empty root; WithValue attaches data.

5. What's the rule for context.WithValue keys?

A string key can collide with another package's. Define `type ctxKey struct{}` (or an unexported typed constant) so the key is unique. And ctx.Value is for cross-cutting request scope (trace IDs, auth) — passing ordinary parameters through it hides dependencies and is an anti-pattern.

Comments

Sign in with GitHub to join the discussion.