A context.Context carries a cancellation signal, an optional deadline and request-scoped values across API boundaries and goroutines, so the whole call tree of one request can be stopped together.
The interface: Deadline(), Done(), Err() and Value(key). A context is immutable and safe to share between goroutines.
Roots: context.Background() and context.TODO(): empty, never cancelled.
Derived: WithCancel, WithTimeout and WithDeadline add a way to stop; WithValue attaches one key/value pair. Each returns a new child; the parent is never modified.
Newer additions: WithCancelCause (Go 1.20), then WithoutCancel, AfterFunc, WithTimeoutCause and WithDeadlineCause (Go 1.21).
Typical uses: stop database queries and outbound HTTP calls when the client disconnects, bound an operation with a timeout, carry a request id or a trace span.
GoMiddle
Context & Cancellation
The context tree, timeouts, deadlines, values, cancellation propagation
Questions closed
0 / 21
- 0Review
- 2Asked often
Questions
At runtime there is none: both return an empty context that is never cancelled, has no deadline and holds no values. The difference is intent. Background() is a deliberate root, TODO() marks a spot where a real context should arrive later.
Use Background() at the top of the call tree: in main and in a long-lived worker that owns its own lifetime. In tests prefer t.Context() (Go 1.24+).
Use TODO() while migrating code that does not accept a ctx yet; it is a marker to grep for later.
Inside a function that already received a ctx, use neither. Starting from Background() there cuts the call off from the caller's deadline and cancellation, the usual reason a timeout "does nothing".
It prints <nil> context canceled, then context canceled context canceled: cancelling a child never touches its parent, while cancelling a parent cancels every context derived from it.
Derived contexts form a tree, and cancellation flows only downward, from a node to all of its descendants.
cancelChild() closes only child.Done(); parent.Err() stays nil.
cancelParent() cancels child2 at once, although its own one-hour timer has not fired. The child takes the parent's reason, so its Err() is context.Canceled, not DeadlineExceeded.
The same rule caps deadlines: a child never lives longer than its parent.
This is what makes contexts fit requests: cancel the request's context and every query and outbound call derived from it stops, while one sub-call giving up does not end the request.
Done() returns a channel that is closed when the context ends, so you can wait on it in a select; Err() says why it ended (context.Canceled or context.DeadlineExceeded) and is nil while the context is alive.
The channel is closed, never sent to, so every goroutine waiting on it wakes up at once.
Once Done() is closed, Err() is guaranteed to be non-nil and never changes again.
The first reason wins: cancel a WithTimeout context before its timer fires and Err() stays context.Canceled.
Background() and TODO() return a nil channel from Done(); a receive on it blocks forever, which is right for a context that never ends.
Check with errors.Is(err, context.DeadlineExceeded), not ==: libraries usually return these errors wrapped.
cancel releases what the derived context holds: it unregisters the child from its parent and stops its timer. Without it they live until the timer fires or the parent ends, and with WithCancel under a long-lived parent, that can be forever.
Write defer cancel() on the line right after the derivation, even if the work usually finishes early. Cancelling a finished context is a cheap no-op and safe to repeat.
A forgotten cancel under a long-lived cancellable parent (for example a server-wide context from signal.NotifyContext) is a slow memory leak: one child stays registered in the parent's children map per call. Under Background() nothing is registered, but a WithTimeout still holds its timer until it fires, and go vet reports the missing cancel either way.
go vet (the lostcancel check) reports a discarded cancel (ctx, _ := ...) and a cancel that is not called on every path.
If the context must outlive the function, return the cancel to the caller, who then owns it.
A context belongs to one call, not to an object: as the first parameter ctx it makes each call's lifetime explicit, while a stored one ties every later call to the deadline, cancellation and values of whichever request created the struct.
A struct is shared across requests. A stored context usually belonged to a request that finished long ago, so later calls fail with context.Canceled, or log another request's trace id and user.
Run(ctx context.Context) error tells the reader the work can be cancelled, and the fixed position lets reviewers and linters spot a missing ctx.
Never pass nil: context.WithCancel(nil) panics and http.NewRequestWithContext(nil, ...) returns an error. If you have nothing better, pass context.TODO().
The accepted exception is a struct that represents one request, such as *http.Request: read it with r.Context(), replace it with r.WithContext(ctx).
No. Cancellation only closes the Done() channel and sets Err(); Go cannot kill a goroutine from outside, so it stops only when its own code notices the signal and returns.
At every blocking point, select on ctx.Done() next to the channel or timer you are waiting on.
Pass ctx into every call that accepts one: db.QueryContext, http.NewRequestWithContext, gRPC stubs. They watch it for you.
In a CPU-bound loop, check ctx.Err() every N iterations or per batch; the check is cheap.
Once you notice, return ctx.Err() and drop partial results.
A goroutine blocked in something that takes no context (time.Sleep, a plain conn.Read, a channel send outside a select) does not react at all. That is how goroutine leaks start.
WithCancel when your own code decides when to stop, WithTimeout when an operation gets a relative budget ("at most 2 s"), and WithDeadline when you already hold an absolute point in time that several steps must share.
**WithCancel: stop the other workers once one has the answer, stop a producer when its consumer leaves, tie background loops to shutdown.
WithTimeout**: the default for a single outbound call or query. It is literally WithDeadline(parent, time.Now().Add(d)).
**WithDeadline**: the deadline came from outside: the caller's budget from a header, the end of a batch window, the parent's deadline minus some headroom.
All three return a cancel you must call, and none can extend the parent: a child deadline later than the parent's has no effect.
signal.NotifyContext is the everyday fourth variant: a context cancelled on SIGINT or SIGTERM, used as the root in main.
From the parent: the incoming request had only 300 ms left, and a derived context can never outlive its parent; WithTimeout effectively keeps the earlier of the two deadlines.
If the parent's deadline is sooner, WithDeadline does not even start a timer, and ctx.Deadline() reports the parent's time.
So a timeout you write is a ceiling on the remaining budget, not a grant. That is what lets one deadline set at the edge bound the whole call tree.
Keep the 5 s anyway: a cron job or a queue consumer calls with no deadline at all, and then yours is the only limit.
When time.Until(deadline) is too short to finish, fail fast instead of taking a pooled connection for a query that cannot complete.
ctx, cancel := context.WithTimeout(ctx, ...) rebinds ctx, so the database call receives the 100 ms cache context, and no call can get more time than the context it is handed.
Every line below that statement inherits the short deadline, the classic bug when a timeout is added to an existing function.
A context.WithTimeout(ctx, 2*time.Second) inside db.Load would not help: a child cannot outlive its parent.
Fix: derive two siblings from the request context (cacheCtx and dbCtx), each with its own defer cancel(). Cancelling one cannot touch the other.
Distinct names make the scope of every budget visible in code review.
It prints bob, alice, <nil>: Value walks up the chain and compares keys with ==, and keys of different types are never equal, even when they hold the same text.
Two packages that both use the plain string "user" as a key silently overwrite each other; the value attached last wins.
Define an unexported key type, usually type userKey struct{}. No other package can create a value of that type, so a collision is impossible.
Export accessors, not the key: WithUser(ctx, u) and UserFrom(ctx) (*User, bool). The type assertion then lives in one place.
Store only request-scoped data: a request id, a trace span, the authenticated user. Optional parameters, a *sql.DB or config are dependencies; pass them as arguments or struct fields, where the compiler checks them.
staticcheck (SA1029) flags built-in types such as string used as context keys.
Canceled means the caller is gone, so stop at once and do not count it as a failure; DeadlineExceeded means the work was too slow (your latency problem), and a retry makes sense only while the overall context still has time left.
Before each retry check the caller's context: if ctx.Err() != nil, return, because every further attempt would fail instantly.
Wait between attempts with a select on ctx.Done() and a timer, never time.Sleep, which keeps the goroutine parked after the caller has left.
A per-attempt timeout that fires is retryable while the parent context is alive: that DeadlineExceeded belongs to the attempt, not to the caller.
At the HTTP edge a client cancel is logged as 499 (the nginx convention) and not alerted on; a deadline is a 504 and counts against your error budget.
Classify with errors.Is: these errors usually arrive wrapped.
defer cancel() runs when Run returns, not at the end of the iteration, and Run returns only at shutdown, so every job leaves a deferred call holding its context and timer, and the heap grows with the number of jobs processed.
A defer belongs to the function, not to the loop body. Each iteration adds one more deferred call to Run's list, and that closure keeps the job's timerCtx reachable.
Releasing late also keeps each job's timer running and the child registered in the parent's children map until its 30 s timeout fires: work that cancel() would undo at once.
Fix: call cancel() as soon as handle returns, or move the body into a runJob(ctx, job) method, where defer cancel() is right again because that function returns after every job.
go vet stays silent: lostcancel only checks that cancel is called on every path, not when. gocritic's deferInLoop (experimental, enable it explicitly in golangci-lint) or revive's defer rule with the loop option flags it, and a heap profile shows the retained memory allocated in the context package.
net/http cancels the request context when the client's connection closes, when an HTTP/2 stream is reset, or when ServeHTTP returns, but only work that was given that context stops.
Build outbound requests with http.NewRequestWithContext(r.Context(), ...). Plain http.NewRequest uses context.Background(), so the upstream call runs on after the client is gone.
Cancelling aborts the dial, the wait for response headers and any resp.Body.Read in progress, so an io.Copy of a streamed body returns at once.
The same holds for db.QueryContext, gRPC calls and your own select loops: no ctx, no cancellation.
With HTTP/1.1 the server starts watching the connection for a disconnect only after the handler has read the whole request body; read the body before slow work if you need prompt cancellation.
Treat context.Canceled from the upstream as "the client left": no 502, no alert. Still defer resp.Body.Close().
The channel is unbuffered, so when the timeout wins the select, nobody ever receives and the worker goroutine blocks on ch <- forever: one leaked goroutine, with its stack and the downloaded body, per timed-out call.
A send on an unbuffered channel waits for a receiver. After fetch returns ctx.Err() the only receiver is gone, and the garbage collector never reclaims a blocked goroutine.
Fix: make(chan result, 1). The single send always fits in the buffer, so the goroutine finishes and exits whether or not anyone reads the result. Sending inside a select with a case <-ctx.Done(): works too.
The buffer does not stop download itself: without a context it still runs to the end. Where the client accepts one, pass ctx and drop the extra goroutine altogether.
Catch it: a goroutine profile: /debug/pprof/goroutine?debug=1 shows one stack with a count in the thousands ending in runtime.chansend1, and debug=2 labels those goroutines [chan send]; in tests, goleak.VerifyNone(t) from go.uber.org/goleak fails when a timed-out call leaves a goroutine behind.
context.WithoutCancel(ctx) (Go 1.21) returns a context that keeps the parent's values but is never cancelled and has no deadline, meant for work that must outlive the request yet still log with its trace id and tenant.
Typical case: an audit write, a cache refill or a notification started from a handler. On r.Context() it would be cancelled the moment the handler returns, so it fails at random, more often when the handler returns quickly.
The deadline goes too, so add your own bound: context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second).
Track the detached goroutine (sync.WaitGroup) so shutdown can wait for it.
Do not use it to silence cancellation errors: if nobody needs the result once the client has left, being cancelled is the correct behaviour.
Before Go 1.21 teams wrote their own context type that forwarded only Value.
Each With* call wraps its parent in a small struct, so contexts form a linked tree: a cancellable node keeps a set of its children and, when cancelled, closes its channel and cancels them in turn, while Value simply asks each parent up the chain.
The node types
valueCtx: the embedded parent plus one key and val.
cancelCtx: a mutex, a lazily created done channel, err, cause and children map[canceler]struct{}.
timerCtx: a cancelCtx plus a time.Timer and the deadline.
Linking to the parent (propagateCancel)
If the parent is already done, the child is cancelled immediately.
If the parent is, or wraps, a standard cancelCtx, the child is added to its children map, with no goroutine involved.
For a custom Context implementation the package uses its AfterFunc method if it has one (Go 1.21+), and otherwise starts a goroutine that waits on both Done channels. Custom contexts are therefore more expensive.
Cancelling
Under the mutex it sets err, closes done, cancels every child and clears the map; afterwards it removes itself from its parent's map. A forgotten cancel is exactly an entry left in that map.
Value
A valueCtx compares its own key and otherwise delegates to its parent, so a lookup is O(depth). That is fine for a handful of values; if you need many, store one struct under a single key.
Use context.WithCancelCause (Go 1.20): cancel(err) records the reason and context.Cause(ctx) returns it on that context and every descendant, while ctx.Err() stays the plain context.Canceled that existing checks rely on.
The first cause wins, like the first cancellation. cancel(nil) sets the cause to context.Canceled.
WithTimeoutCause and WithDeadlineCause (Go 1.21) attach a sentinel to a deadline, so with nested budgets you learn which layer ran out. The cause applies only when the timer fires; an explicit cancel() still reports Canceled.
On a context that was never given a cause, Cause(ctx) returns the same as Err(), so calling it is always safe.
errgroup.WithContext from golang.org/x/sync already cancels with the first worker's error as the cause.
Keep control flow on Err() and errors.Is; use the cause for diagnosis: the log line, the span, the error you return.
Register a callback with context.AfterFunc(ctx, f) (Go 1.21): f runs in its own goroutine once ctx is done, and nothing sits waiting in the meantime.
The hand-rolled version, go func() { <-ctx.Done(); c.Close() }(), parks a goroutine per call, and with a long-lived ctx it never exits after a successful call.
AfterFunc returns stop. stop() returns true if it prevented f from running and false if f has already started, so you can tell whether a failure was probably caused by the cancellation.
f runs concurrently with your code, so what it touches must be safe for that: Close racing with Run is the client's contract to check.
The same trick bridges contexts to APIs that only know deadlines: conn.SetReadDeadline(time.Now()) unblocks a pending Read on a net.Conn.
If ctx is already done when you register, f starts immediately.
No. Shutdown closes the listeners and idle connections, then waits for active requests to finish; its ctx only bounds how long you wait, and when it expires the handlers keep running with a live r.Context().
To reach handlers, set http.Server.BaseContext: every request context derives from it, so cancelling it cancels r.Context() in all in-flight requests at once.
A workable sequence: fail readiness, call Shutdown with a generous budget, and only if it returns an error cancel the base context so the stragglers give up, then wait once more briefly.
Hijacked connections (WebSockets) and goroutines started by handlers are not tracked by Shutdown; see the graceful-shutdown question in the Go HTTP services topic.
A handler that never checks its ctx ignores both levers: cancellation is cooperative.
A context does not cross the network by itself, so each hop must send its remaining budget and the next hop must turn it back into a deadline; then work stops everywhere once the original caller has given up.
Send a duration (time.Until(deadline)), not a timestamp: two machines' clocks disagree. gRPC does this with grpc-timeout; over HTTP you invent a header.
Over plain HTTP, a middleware reads the header and calls context.WithTimeout(r.Context(), d), clamped to a server maximum: a client-supplied deadline is untrusted input.
Derive outbound calls from the incoming ctx, never from context.Background() with a fixed timeout; that resets the budget at every hop, which is B's bug.
Subtract headroom at each hop for the round trip and for sending your own error, and do not call downstream at all once the budget is spent.
Deadlines should only shrink going down the call tree; a per-hop timeout stays as an upper cap, not a replacement.