The Context Package

The standard-library context package defines a single small interface — context.Context — that carries three orthogonal things across API boundaries: a deadline, a cancellation signal, and a set of request-scoped values (per the package docs). It exists because in a Go server every incoming request runs in its own goroutine and typically spawns more goroutines to talk to databases, caches, and other services; when the request is abandoned — the client hangs up, a timeout fires, a sibling call fails — all of those goroutines must learn to stop, or they leak (go.dev/blog/context). Context is the wire along which that “stop now” message travels. It is not a general-purpose container, not a way to pass optional parameters, and not a replacement for explicit function arguments — it is a cancellation-propagation primitive with a values side-channel bolted on.

Mental Model

The right way to picture context is a tree of nodes that mirrors your call tree. The root is context.Background() — an empty context that is never canceled and has no deadline. Every call to a With… constructor (WithCancel, WithTimeout, WithDeadline, WithValue, WithCancelCause) takes a parent context and returns a child. The child is wired to the parent so that cancellation flows strictly downward: cancelling a node cancels that node and its entire subtree, but a child can never cancel its parent. This is why Context has no Cancel() method — the cancel function is returned separately from the constructor, so only code that holds it (normally the goroutine that created the scope) can trigger cancellation.

graph TD
    BG["context.Background()<br/>root — never canceled"]
    REQ["WithTimeout 5s<br/>(per-request)"]
    V["WithValue userID=42"]
    DB["WithCancel<br/>(DB query)"]
    RPC["WithTimeout 1s<br/>(RPC call)"]
    BG --> REQ
    REQ --> V
    V --> DB
    V --> RPC
    cancelSig["timeout fires / cancel() called"] -.->|"closes Done()"| REQ
    REQ -.->|"propagates"| V
    V -.->|"propagates"| DB
    V -.->|"propagates"| RPC

The context tree mirrors the call tree. Cancellation enters at any node and flows strictly downward to the whole subtree; it never flows up. The insight: where you create a child context determines exactly which goroutines a given cancellation will reach — pick the scope deliberately.

A context value should be threaded as the first parameter of every function on a request path, conventionally named ctx: func DoThing(ctx context.Context, arg Arg) error. It is deliberately not stored in a struct (go.dev/blog/context-and-structs), because a struct outlives a single call and a context is scoped to one operation; stashing it in a struct field makes its lifetime ambiguous and invites use of a stale, already-canceled context.

Mechanical Walk-through

The Context interface has exactly four methods (context.go):

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

Done() returns a receive-only channel that is closed — not sent to — when the context is canceled. Closing rather than sending is the crucial design choice: a closed channel makes every receiver proceed immediately and forever, so one close broadcasts cancellation to an unbounded number of waiting goroutines at once. A consumer integrates this into a select: it either makes progress on its real work or returns when <-ctx.Done() unblocks.

Err() reports why Done() closed. Before cancellation it returns nil. After cancellation it returns one of two sentinel errors: context.Canceled if a cancel function was called, or context.DeadlineExceeded if a deadline elapsed. These are package-level vars, so callers test them with errors.Is(err, context.Canceled).

Deadline() reports the absolute time at which the context will auto-cancel; ok is false when no deadline is set. Libraries use it to size sub-timeouts (e.g., “I have 800 ms left, so I’ll give this RPC 700 ms”).

Value(key) walks up the parent chain looking for a matching key — a linear search, not a map lookup. This is why values are for genuinely request-scoped data (a trace ID, an authenticated user) and not for passing function arguments: every Value call is O(depth of the value chain).

The cancellation mechanism itself. WithCancel(parent) builds a cancelCtx struct holding a lazily-created done channel and a children set. The returned CancelFunc calls the internal cancel method, which closes done, sets err, recursively cancels every child in the set, and detaches the node from its parent. To propagate a parent’s cancellation, the constructor calls propagateCancel: if the parent is itself a cancelCtx, the child registers in the parent’s children map; if the parent is some foreign context type whose Done() channel is non-nil, the runtime instead spins up a small goroutine that blocks on select { case <-parent.Done(): child.cancel(...) case <-child.Done(): }. This is why calling cancel is mandatory even when a timeout will also fire: skipping it leaves the child registered in the parent’s children map (a memory leak) or leaves that helper goroutine blocked (a goroutine leak). The standard advice — defer cancel() on the line after the constructor — exists precisely to make this leak impossible.

WithTimeout(parent, d) is literally WithDeadline(parent, time.Now().Add(d)). WithDeadline builds a timerCtx that embeds a cancelCtx plus a time.Timer; when the timer fires it calls cancel with DeadlineExceeded. If the parent already has an earlier deadline, the child adopts the parent’s deadline — a child can only ever tighten, never loosen, an inherited deadline.

WithCancelCause and Cause (added Go 1.20). WithCancelCause (go.dev/doc/go1.20) returns a CancelCauseFuncfunc(cause error) — instead of a plain CancelFunc. The context’s Err() still returns the generic context.Canceled, but context.Cause(ctx) returns the specific error you passed to the cancel function. This separates the machine-readable signal (Err() for errors.Is checks) from the human-readable diagnosis (Cause() for logs). WithDeadlineCause and WithTimeoutCause (added Go 1.21, go.dev/doc/go1.21) do the same for the deadline path.

AfterFunc (added Go 1.21). context.AfterFunc(ctx, f) arranges for f to run in its own goroutine once ctx is canceled — and runs it immediately if ctx is already canceled. It returns a stop func() bool; calling stop removes the registration and returns true if f had not yet been scheduled. This replaces the hand-rolled go func(){ <-ctx.Done(); cleanup() }() idiom with one that does not leak a goroutine when the context is never canceled.

WithoutCancel (added Go 1.21). context.WithoutCancel(parent) returns a child that keeps the parent’s values but is not canceled when the parent is. Its use case: a request handler wants to fire off a best-effort background write (audit log, metrics flush) that must survive the request returning.

Code Examples

A canonical HTTP-style handler with a per-request timeout:

func handler(w http.ResponseWriter, r *http.Request) {
    // 1. Derive a 5s budget for this request from the request's own context.
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    // 2. ALWAYS defer cancel — releases the timer and detaches from parent
    //    even if fetchUser returns before the timeout.
    defer cancel()
 
    user, err := fetchUser(ctx, userID(r))
    if err != nil {
        // 3. Distinguish timeout from other failures for the right HTTP status.
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(user)
}
 
func fetchUser(ctx context.Context, id int) (*User, error) {
    // 4. The standard library is context-aware: this query is aborted
    //    the instant ctx.Done() closes.
    row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id=$1", id)
    var u User
    // 5. err here will be context.DeadlineExceeded if the timeout won the race.
    return &u, row.Scan(&u.Name)
}

Line 1 derives the child from r.Context(), not Background() — so if the client disconnects the database query is canceled too. Line 2 is non-negotiable; without it the timerCtx keeps a live time.Timer and stays linked to its parent. Lines 3–5 show the consumer side: real cancellation handling means checking the error and reacting, not just passing ctx along.

A hand-written cancellable loop — the pattern every long-running worker must follow:

func produce(ctx context.Context, out chan<- int) error {
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()        // canceled: stop, report why
        case out <- i:              // sent one item, keep going
        }
    }
}

The select makes the goroutine responsive to cancellation even while blocked on a send. A loop that only does out <- i with no ctx.Done() arm is the single most common source of Goroutine Leaks.

Using WithCancelCause to attach a diagnostic reason:

ctx, cancel := context.WithCancelCause(context.Background())
go func() {
    if err := validate(); err != nil {
        cancel(fmt.Errorf("validation failed: %w", err))
    }
}()
<-ctx.Done()
fmt.Println(ctx.Err())        // "context canceled"  — generic, for errors.Is
fmt.Println(context.Cause(ctx)) // "validation failed: ..." — specific, for logs

Common Misunderstandings and Failure Modes

Forgetting cancel(). go vet’s lostcancel check flags a cancel that is never used precisely because it leaks a timer and a tree linkage. defer cancel() immediately after the constructor is the fix; it is harmless to call cancel on an already-canceled context.

Treating Value as a parameter bus. Putting a database handle or a config struct in context.WithValue is an anti-pattern: it hides dependencies, makes them untyped (any), and turns every access into an up-the-chain linear scan. Values are for data that genuinely crosses every API boundary and belongs to the request, not the function — trace IDs, auth principals, locale.

Unexported key types. context.WithValue(ctx, "userID", 42) invites a collision: two packages both using the string "userID" silently overwrite each other. The fix is a private named type — type ctxKey int; const userIDKey ctxKey = 0 — so the key is unique across the whole program and unforgeable by other packages.

Done() is closed, never sent to. Code that does <-ctx.Done() expecting a value misunderstands the mechanism: the receive yields the zero struct{}{} the moment the channel closes, and forever after. There is nothing to “consume.”

A nil context. Passing nil where a context.Context is expected panics on the first method call. When you genuinely do not yet have one, pass context.TODO() — semantically identical to Background() but a searchable marker that the context plumbing is unfinished.

Storing context in a struct. Because a struct can outlive the operation, a stored ctx field can be read long after it was canceled, or shared across unrelated calls. Pass it explicitly; if a struct method needs one, take it as a parameter (go.dev/blog/context-and-structs).

Alternatives and When to Choose Them

The pre-context idiom — a bare done chan struct{} closed to broadcast cancellation, described in Pipeline Pattern and go.dev/blog/pipelines — still works and is appropriate for internal pipeline plumbing where there is no deadline and no values to carry. context supersedes it the moment you need a deadline, a cancellation cause, request-scoped values, or interoperability with the standard library and third-party APIs (every one of which takes a context.Context, not a raw channel).

For waiting on a group of goroutines and collecting the first error, context alone is too low-level — that is the job of errgroup and Structured Concurrency, which layers error propagation and a goroutine limit on top of a context. For mere counting of goroutines with no cancellation, a sync.WaitGroup is lighter.

Production Notes

Every modern Go server is built around context plumbing: net/http puts a context on every *http.Request (r.Context()), canceled automatically when the client disconnects; gRPC propagates deadlines across the wire so a 2-second budget set by the caller is enforced by the callee. The reliability rule that follows is: derive, never originate. Library and handler code derives children from the context it was given; only the program’s true entry point (main, a test, a request acceptor) starts from Background(). Deriving everywhere is what makes a single top-level timeout or a single client disconnect cascade correctly through dozens of goroutines.

A subtle production pitfall: a context’s deadline is a budget shared by everything in its subtree. If a request has a 1-second deadline and makes five sequential RPCs, each RPC sees the same shrinking remaining time — there is no per-call reset. Engineers debugging “why did this fast call time out?” often discover the budget was already spent upstream. ctx.Deadline() is the tool for reasoning about and subdividing that budget explicitly.

See Also