Skip to content

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.

// Root contexts — never carry cancellation
context.Background() // top-level root, use in main or test setup
context.TODO() // placeholder when you are unsure which context to use
// Derived contexts — add cancellation/timeout on top of a parent
ctx, 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"]
Cancelling a context cancels every context derived from it, except one created with WithoutCancel

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() // always

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, nil
case <-ctx.Done():
return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
}

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 string
const 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.

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 sentinel
context.Cause(ctx) // errQuotaExceeded -- the specific reason

ctx.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.

A short checklist that covers nearly every idiomatic-context violation in generated code:

  • ctx is always the first parameter, named ctx, never bundled into a struct field.
  • Never pass a nil context — use context.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 fresh context.Background().
  • A main that needs to shut down on SIGINT/SIGTERM uses signal.NotifyContext(context.Background(), os.Interrupt) rather than hand-rolling a signal channel.
  • Every WithCancel/WithTimeout/WithDeadline gets a defer cancel() right next to its creation, covered already in this lesson’s callout.

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
}

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
}
What you getBenefitCost
context.WithCancelpropagates cancellation down the treemust always call cancel(), or the goroutine/context leaks
context.WithTimeoutautomatic deadlinemust handle context.DeadlineExceeded
context.WithValuepasses a value through the call stacknot type-safe; use an unexported key to avoid collisions
ctx.Done() channelnon-blocking check via selectmust be checked in every loop iteration that might run long
  • context.WithValue is 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 check ctx.Done() itself
  • A context can carry a value of any type safely — context.Value() returns any; you need a type assertion, and the result can be nil

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 ctx ever 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 to context.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 explicit ctx.Err() check inside the loop body, cancellation is invisible to it.
  • Does error handling compare err == context.Canceled directly, when the error might have been wrapped with %w somewhere upstream? errors.Is is 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:11
main.main()
/tmp/ctxassert/main.go:16 +0x34

The 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.

What is the correct idiomatic way to handle the cancel function from context.WithCancel?
What channel does ctx.Done() return, and when is it closed?
What does ctx.Err() return when the context deadline has exceeded?
Why should you use an unexported package-private type as a context.WithValue key?
What does this print?
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errQuotaExceeded)
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
What happens when this runs?
type ctxKey string

func requestID(ctx context.Context) string {
    return ctx.Value(ctxKey("reqID")).(string)
}

func main() {
    fmt.Println(requestID(context.Background()))
}