Context
What is context.Context?
Section titled “What is context.Context?”context.Context is a standard interface that carries:
- A cancellation signal — a goroutine can check
ctx.Done()to learn that it should stop working - An optional deadline or timeout — work is automatically cancelled when the clock expires
- Request-scoped key/value pairs — small amounts of metadata like a request ID or authentication token
The context package solves a concrete problem: when a server request is cancelled (the user closes the browser, the upstream times out), how does every in-flight goroutine spawned for that request know to stop? Passing context.Context as the first argument to every I/O function is the Go-standard answer.
Creating contexts
Section titled “Creating contexts”// Root contexts — never carry cancellationcontext.Background() // top-level root, use in main or test setupcontext.TODO() // placeholder when you are unsure which context to use
// Derived contexts — add cancellation/timeout on top of a parentctx, cancel := context.WithCancel(parent)ctx, cancel := context.WithTimeout(parent, 5*time.Second)ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))ctx2 := context.WithValue(parent, key, value)Derived contexts form a tree. Cancelling a parent automatically cancels all its children.
flowchart TD BG["context.Background()"] --> Req["WithCancel (request ctx)"] Req --> DB["WithTimeout (db call)"] Req --> Val["WithValue (adds reqID)"] Req -.cancel called.-> Cancelled["Req, DB, and Val all cancelled together"] Req --> WOC["WithoutCancel (background audit)"] WOC -.immune to Req's cancel, keeps its values.-> Survives["outlives the request"]
The cancellation contract
Section titled “The cancellation contract”When you call context.WithCancel or context.WithTimeout, you receive two values: the derived context and a cancel function. You must call cancel() when the work is done — even if the context expires naturally. If you don’t, the parent context keeps a reference to the child until the parent is also cancelled, which leaks resources.
ctx, cancel := context.WithCancel(context.Background())defer cancel() // alwaysListening for cancellation
Section titled “Listening for cancellation”Inside a goroutine, ctx.Done() returns a channel that is closed when the context is cancelled or times out. Use select to multiplex it with real work:
select {case result := <-workCh: return result, nilcase <-ctx.Done(): return "", ctx.Err() // context.Canceled or context.DeadlineExceeded}context.WithValue
Section titled “context.WithValue”context.WithValue attaches a key/value pair to the context. Use only for request-scoped data that crosses API boundaries (request ID, auth token). Do not use it as a general-purpose parameter-passing mechanism — put those in function arguments instead.
To avoid key collisions with other packages, always use a package-private, unexported type as the key:
type contextKey stringconst requestIDKey contextKey = "requestID"Prefer a typed getter function over a bare type assertion at every call site — func requestID(ctx context.Context) (string, bool) { v, ok := ctx.Value(requestIDKey).(string); return v, ok } — so a missing value is a checked false instead of a panic. The comma-ok form matters here for exactly the reason covered in the Reading AI-generated code section below.
Cause: why was this context cancelled?
Section titled “Cause: why was this context cancelled?”ctx.Err() only ever returns one of two sentinel values — context.Canceled or context.DeadlineExceeded — which tells you that something happened but never why. context.WithCancelCause (and context.WithTimeoutCause for the deadline case) let you attach a real reason, retrieved later with context.Cause:
ctx, cancel := context.WithCancelCause(parent)cancel(errQuotaExceeded) // the "cause"
ctx.Err() // context.Canceled -- always the generic sentinelcontext.Cause(ctx) // errQuotaExceeded -- the specific reasonctx.Err() stays a stable sentinel that every caller can compare against; context.Cause(ctx) is the richer error for logging or a client response. If cancel is called with nil, the cause is context.Canceled — same as plain WithCancel.
WithoutCancel: detaching from a parent’s cancellation
Section titled “WithoutCancel: detaching from a parent’s cancellation”Background work that must outlive the request that started it — a cache refresh, an audit log write — should not inherit the parent’s cancellation, but usually should still carry its request-scoped values (a trace ID, for logging). context.WithoutCancel (Go 1.21) does exactly that: the returned context never reports Done, Err, or Cause from the parent’s cancellation, while Value lookups still walk up to the parent normally.
func handleRequest(ctx context.Context) { go auditLog(context.WithoutCancel(ctx), "request handled") // ...respond to the request; ctx may be cancelled the instant this returns}Reaching for context.Background() instead is the common AI-generated mistake here — it detaches from cancellation too, but it also throws away every value the original context carried, including anything a logging or tracing middleware attached upstream.
AfterFunc: reacting to cancellation without a select
Section titled “AfterFunc: reacting to cancellation without a select”context.AfterFunc(ctx, f) (Go 1.21) runs f in its own goroutine the moment ctx is done — including immediately, in its own goroutine, if ctx is already done when you call it. It returns a stop function that cancels the association; stop() returns true only if it prevented f from running (if f has already started, stop returns false and does not wait for f to finish). This is a lighter-weight alternative to spinning up a small goroutine with a select on ctx.Done() purely to run a cleanup callback.
The rules of the road
Section titled “The rules of the road”A short checklist that covers nearly every idiomatic-context violation in generated code:
ctxis always the first parameter, namedctx, never bundled into a struct field.- Never pass a
nilcontext — usecontext.TODO()when it is genuinely unclear which context belongs, and replace it once the real one is threaded through. - An HTTP handler gets its context from the request:
r.Context(), never a freshcontext.Background(). - A
mainthat needs to shut down onSIGINT/SIGTERMusessignal.NotifyContext(context.Background(), os.Interrupt)rather than hand-rolling a signal channel. - Every
WithCancel/WithTimeout/WithDeadlinegets adefer cancel()right next to its creation, covered already in this lesson’s callout.
errors.Is for context errors
Section titled “errors.Is for context errors”ctx.Err() returns the exact sentinel values context.Canceled and context.DeadlineExceeded, so a plain == comparison against them works today. Prefer errors.Is(err, context.Canceled) anyway once the error has passed through anything that might wrap it — fmt.Errorf("fetch: %w", ctx.Err()) — because == stops working the moment the sentinel is wrapped, while errors.Is unwraps the chain to find it:
if errors.Is(err, context.Canceled) { // the caller gave up; not worth logging as a failure} else if errors.Is(err, context.DeadlineExceeded) { // ran out of time; may be worth retrying with a longer budget}Checking ctx.Err() in a CPU-bound loop
Section titled “Checking ctx.Err() in a CPU-bound loop”A select on ctx.Done() only helps when the loop body actually performs a channel operation. A tight, CPU-bound loop with no channel ops anywhere in it — computing over a large slice, say — never reaches a select at all, so it will run to completion even after the caller has cancelled it:
for i, item := range hugeSlice { if i%1024 == 0 && ctx.Err() != nil { // poll periodically, not every iteration return ctx.Err() } process(item)}Checking every single iteration is usually wasted overhead for a cheap per-item operation; checking on a stride (every 1024th item, or every N milliseconds via time.Since) keeps the overhead negligible while still responding to cancellation promptly.
package main
import ( "context" "fmt")
func fetchData(ctx context.Context, id int) (string, error) { select { case <-ctx.Done(): return "", ctx.Err() default: return fmt.Sprintf("data-%d", id), nil }}
type ctxKey string
func main() { // WithCancel: normal fetch succeeds ctx, cancel := context.WithCancel(context.Background()) defer cancel()
result, err := fetchData(ctx, 1) if err != nil { fmt.Println("error:", err) } else { fmt.Println("got:", result) }
// Cancel, then try again — now the context is done cancel() result2, err2 := fetchData(ctx, 2) if err2 != nil { fmt.Println("cancelled:", err2) } else { fmt.Println("got:", result2) }
// WithValue: attach request-scoped metadata ctx3 := context.WithValue(context.Background(), ctxKey("requestID"), "abc-123") rid := ctx3.Value(ctxKey("requestID")).(string) fmt.Println("requestID:", rid)} The second runnable snippet below covers WithCancelCause/Cause, WithoutCancel, and AfterFunc together.
package main
import ( "context" "errors" "fmt")
var errQuotaExceeded = errors.New("quota exceeded")
type ctxKey string
func main() { // WithCancelCause / Cause: record *why* a context was cancelled, not just that it was. ctx, cancel := context.WithCancelCause(context.Background()) cancel(errQuotaExceeded) fmt.Println("Err():", ctx.Err()) // the generic sentinel fmt.Println("Cause():", context.Cause(ctx)) // the specific reason fmt.Println("errors.Is Canceled:", errors.Is(ctx.Err(), context.Canceled)) fmt.Println("errors.Is quota:", errors.Is(context.Cause(ctx), errQuotaExceeded))
// WithoutCancel: detach from the parent's cancellation but keep its values. parent := context.WithValue(context.Background(), ctxKey("reqID"), "abc-123") parentCtx, parentCancel := context.WithCancel(parent) parentCancel() // parent is already cancelled bg := context.WithoutCancel(parentCtx) fmt.Println("parent ctx.Err():", parentCtx.Err()) // context.Canceled fmt.Println("detached ctx.Err():", bg.Err()) // nil -- immune to the parent's cancellation fmt.Println("detached still has value:", bg.Value(ctxKey("reqID")))
// AfterFunc: run a callback when a context is done, without a manual select. done := make(chan struct{}) afterCtx, afterCancel := context.WithCancel(context.Background()) context.AfterFunc(afterCtx, func() { fmt.Println("AfterFunc ran because ctx was cancelled") close(done) }) afterCancel() <-done} Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| context.WithCancel | propagates cancellation down the tree | must always call cancel(), or the goroutine/context leaks |
| context.WithTimeout | automatic deadline | must handle context.DeadlineExceeded |
| context.WithValue | passes a value through the call stack | not type-safe; use an unexported key to avoid collisions |
| ctx.Done() channel | non-blocking check via select | must be checked in every loop iteration that might run long |
Common Misconceptions
Section titled “Common Misconceptions”context.WithValueis meant for passing business data — it is for request-scoped metadata only (trace ID, auth token)cancel()stops the goroutine immediately —cancel()only sends a signal through a channel; the goroutine must checkctx.Done()itself- A context can carry a value of any type safely —
context.Value()returnsany; you need a type assertion, and the result can be nil
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet uses context.Context, check for these before trusting it:
- Does every
ctx.Value(key)call use a comma-ok type assertion (v, ok := ctx.Value(key).(T)) instead of a bare one (ctx.Value(key).(T))? A bare assertion panics the instant the key was never set on that context. - Is
ctxever stored in a struct field instead of being passed as the first parameter of every function that needs it? - Does background work that must survive the request use
context.WithoutCancel(ctx)— or does it fall back tocontext.Background()and silently lose every value the original context carried? - Is there a
case <-ctx.Done()anywhere a CPU-bound loop with no channel operations is supposed to be cancellable? Without an explicitctx.Err()check inside the loop body, cancellation is invisible to it. - Does error handling compare
err == context.Canceleddirectly, when the error might have been wrapped with%wsomewhere upstream?errors.Isis required once wrapping is possible.
🐛 Spot the bug
type ctxKey string
func requestID(ctx context.Context) string { return ctx.Value(ctxKey("reqID")).(string) // bare assertion}
func main() { ctx := context.Background() // reqID was never attached to this context fmt.Println(requestID(ctx))}Show the answer
ctx.Value(ctxKey("reqID")) returns nil (as an any) because nothing ever called context.WithValue with that key on this context or any of its ancestors. Asserting a nil any directly to string — a concrete, non-interface type — panics rather than returning the zero value:
panic: interface conversion: interface {} is nil, not string
goroutine 1 [running]:main.requestID(...) /tmp/ctxassert/main.go:11main.main() /tmp/ctxassert/main.go:16 +0x34The fix is the comma-ok form: v, ok := ctx.Value(ctxKey("reqID")).(string); if !ok { return "" } (or return an error, depending on whether a missing request ID is expected). This is exactly the typed-getter pattern recommended earlier in this lesson’s context.WithValue section.