Context for Cancellation
A context.Context is Go’s standard way to carry cancellation signals, timeouts, and small pieces of request-scoped data across API boundaries and between goroutines. Whenever a program needs to say “stop what you’re doing, the caller no longer needs the result” — an HTTP request that timed out, a user who navigated away, a shutdown signal arriving — a Context is how that message travels through the call stack. It is one of the most important tools for writing concurrent Go code that does not leak goroutines or waste work on results nobody will ever read.
Overview: How Context Works
The context package, part of the standard library, defines a small interface:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
You almost never implement this interface yourself. Instead you build a tree of contexts using the package’s constructor functions, starting from a root:
context.Background()— the root of almost every context tree: no deadline, never cancelled, carries no values. Used inmain, in tests, and at the top of request-handling code.context.TODO()— behaves identically toBackground(), but signals “this code should eventually take a real context; one hasn’t been threaded through here yet.” Prefer it as a placeholder while refactoring.
Each of the following functions takes a parent Context and returns a new, derived child Context, plus (except for WithValue) a cancel function:
context.WithCancel(parent)— returns a child context and acancelfunction that, when called, cancels that context and every context derived from it.context.WithTimeout(parent, duration)— likeWithCancel, but also cancels automatically oncedurationelapses.context.WithDeadline(parent, time)— likeWithTimeout, but you supply an absolutetime.Timeinstead of a duration.context.WithValue(parent, key, value)— returns a child context that carries one extra key/value pair, readable withValue.
Contexts form a tree, not a flat list. Calling context.WithCancel(parent) registers the new child with its parent internally, so cancelling the parent cancels the child too, and the child’s children, and so on down the whole subtree. Cancelling a child, however, never affects its parent or its siblings — cancellation only flows downward. This is exactly the shape you want: if an HTTP handler’s top-level context is cancelled because the client disconnected, every downstream database query, RPC call, and sub-goroutine spawned for that request should stop, without affecting unrelated requests being served concurrently.
The Done() method returns a channel of type <-chan struct{}. Under the hood that channel starts open, and cancelling the context closes it — nothing is ever sent on it. This matters because closing a channel is a broadcast: every goroutine blocked in a select on ctx.Done() wakes up at the same instant, no matter how many goroutines are waiting. A context is only ever cancelled once; calling cancel a second time, or letting a timeout fire after you already cancelled manually, is a harmless no-op. Once the Done() channel is closed, Err() explains why: context.Canceled if something called cancel(), or context.DeadlineExceeded if a timeout or deadline elapsed first.
Internally, WithTimeout is implemented in terms of WithDeadline, which is implemented in terms of WithCancel plus a time.Timer that invokes the internal cancel function when it fires. That is why every With* constructor gives you back a cancel function — even WithTimeout, whose context will eventually cancel itself anyway. You must still call it, typically with defer cancel() right after creating the context. If you don’t, the timer and the bookkeeping entry the child keeps in its parent stay alive until the deadline actually fires, which can leak memory in a long-running server handling many short-lived contexts. go vet even has a dedicated check, lostcancel, that flags a discarded cancel function.
WithValue is different in kind: it doesn’t create a cancellation boundary at all, it just attaches one key/value pair that Value(key) can retrieve, walking up the parent chain until it finds a match (or reaches the root and returns nil). It exists for request-scoped metadata that must cross API boundaries you don’t control — a request ID for logging, an authenticated user, a tracing span — not as a way to sneak extra parameters into a function instead of adding a parameter. If a value is required for a function to do its job, it belongs in the function signature, not buried inside a context.
Finally, a Context is meant to be passed explicitly, never stored. The convention, enforced by nothing but consistently followed across the Go ecosystem, is that ctx is the first parameter of any function that needs one, and it is never stashed in a struct field.
Syntax
ctx, cancel := context.WithCancel(parentCtx)
ctx, cancel := context.WithTimeout(parentCtx, duration)
ctx, cancel := context.WithDeadline(parentCtx, deadlineTime)
ctx := context.WithValue(parentCtx, key, value)
defer cancel()
| Piece | Meaning |
|---|---|
parentCtx |
An existing Context to derive from — context.Background() at the root, or a Context passed into your function. |
ctx |
The new, derived Context. Pass this to any function or goroutine that should respect the same cancellation. |
cancel |
A function with signature func(). Calling it cancels ctx and everything derived from it. Must always be called, usually via defer. |
duration |
A time.Duration, e.g. 5*time.Second, after which ctx cancels itself. |
key, value |
any values. The key should be an unexported custom type, never a plain string, to avoid collisions between packages. |
Examples
Example 1: Cancelling a Context Manually
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
fmt.Println("before cancel, err:", ctx.Err())
cancel()
fmt.Println("after cancel, err:", ctx.Err())
select {
case <-ctx.Done():
fmt.Println("done channel is closed")
default:
fmt.Println("done channel is still open")
}
}
Output:
before cancel, err: <nil>
after cancel, err: context canceled
done channel is closed
Before cancel() is called, ctx.Err() is nil — the context is still active. Calling cancel() closes the internal Done() channel, so Err() immediately starts reporting context.Canceled, and a select that checks ctx.Done() against a default case takes the Done() branch instead of falling through, because receiving from a closed channel never blocks.
Example 2: A Timeout Racing Real Work
package main
import (
"context"
"fmt"
"time"
)
func doWork(ctx context.Context, resultCh chan<- string) {
select {
case <-ctx.Done():
resultCh <- "cancelled: " + ctx.Err().Error()
case <-time.After(2 * time.Second):
resultCh <- "work finished"
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
resultCh := make(chan string)
go doWork(ctx, resultCh)
fmt.Println(<-resultCh)
}
Output:
cancelled: context deadline exceeded
doWork races two channels in a select: the context’s Done() channel and a 2-second timer from time.After. Because the context was created with a 50-millisecond timeout, ctx.Done() always closes long before the 2-second timer fires, so the ctx.Done() branch always wins and reports context.DeadlineExceeded. This is the pattern real code uses to bound how long it will wait for a slow dependency: give the operation a context, and let whichever finishes first — the real work or the deadline — decide the outcome.
Example 3: Carrying a Value Through a Context
package main
import (
"context"
"fmt"
)
type ctxKey string
const requestIDKey ctxKey = "requestID"
func process(ctx context.Context) {
if id, ok := ctx.Value(requestIDKey).(string); ok {
fmt.Println("processing request:", id)
} else {
fmt.Println("no request id found")
}
}
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "abc-123")
process(ctx)
process(context.Background())
}
Output:
processing request: abc-123
no request id found
The key is a small unexported type, ctxKey, not a bare string — that guarantees no other package’s context key can accidentally collide with requestIDKey, even if that package also happens to use the string "requestID" as a key. Value returns any, so callers must use a type assertion (with the two-result form, to avoid panicking when the value is absent) before using it. When process is called with a plain context.Background(), the lookup fails and ok is false.
How It Works Step by Step
Tracing Example 2 shows the mechanics of a timeout in practice:
context.WithTimeoutcreates a child ofcontext.Background()and starts an internal 50-millisecond timer.mainlaunchesdoWorkin a new goroutine and immediately blocks trying to receive fromresultCh.- Inside
doWork, theselectstatement blocks on two channel receives at once:ctx.Done()andtime.After(2 * time.Second). - After roughly 50 milliseconds, the context’s internal timer fires, which closes the
Done()channel and setsctx.Err()tocontext.DeadlineExceeded. - The
selectindoWorkwakes up on the now-readyctx.Done()case (the 2-second timer never gets there) and sends a formatted string onresultCh. main‘s blocked receive onresultChunblocks, andfmt.Printlnprints the result.
Cancellation propagation down a context tree works the same way, just without a timer involved:
package main
import (
"context"
"fmt"
)
func main() {
parent, parentCancel := context.WithCancel(context.Background())
child, childCancel := context.WithCancel(parent)
defer childCancel()
parentCancel()
select {
case <-child.Done():
fmt.Println("child cancelled because:", child.Err())
default:
fmt.Println("child still active")
}
}
Output:
child cancelled because: context canceled
When child was created from parent, it registered itself with parent so that it can be notified later. Calling parentCancel() walks that internal list of registered children and cancels each one, which is why child.Done() is already closed by the time the select runs, even though nothing ever called childCancel() directly.
Common Mistakes
Mistake 1: Discarding the cancel function
It’s tempting to throw away cancel when a context already has a timeout, reasoning that it will expire on its own eventually:
func fetch(url string) {
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
// cancel is discarded -- the timer and its bookkeeping
// stay alive until the full 5 seconds pass, every call
doRequest(ctx, url)
}
If fetch is called frequently and each request finishes in milliseconds, the discarded timers pile up and keep resources alive far longer than necessary. Always capture and call cancel, typically deferred right where the context is created:
func fetch(url string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
doRequest(ctx, url)
}
Mistake 2: Storing a Context in a struct field
Stashing a context on a struct looks convenient, but it invites bugs where a stale or already-cancelled context from an earlier call gets reused for unrelated work:
type Server struct {
ctx context.Context
}
func (s *Server) Handle() {
// s.ctx might belong to a request that finished
// long ago, or might not be set at all
doWork(s.ctx)
}
Pass the context explicitly into every method that needs it instead, so each call carries its own, correctly-scoped context:
type Server struct {
// no stored context
}
func (s *Server) Handle(ctx context.Context) {
doWork(ctx)
}
Best Practices
- Always call the
cancelfunction returned byWithCancel,WithTimeout, orWithDeadline, typically viadefer, even if you expect the context to expire on its own. - Make
ctxthe first parameter of any function that needs it, and never store a Context in a struct field. - Never pass a
nilContext; usecontext.TODO()if you aren’t yet sure which context to thread through. - Use
WithValuesparingly, only for request-scoped data that crosses API boundaries you don’t control (request IDs, auth info, tracing spans) — never as a substitute for an explicit function parameter. - Define context keys as an unexported custom type, not a bare
string, to avoid collisions with keys from other packages. - Creating a context does nothing by itself — only code that actually selects on
ctx.Done()or checksctx.Err()will stop early when it’s cancelled. - Derive contexts through the call chain instead of calling
context.Background()deep inside your code, or you lose the caller’s cancellation and values. - Set timeouts as close as possible to the outermost boundary that knows the real time budget, and let that budget flow down through derived contexts rather than each layer inventing its own.
Practice Exercises
- Write a function that simulates work with
time.Sleepinside a goroutine, called with acontext.WithTimeoutof 100 milliseconds. Use aselectto print whether the work finished first or the context timed out first, for a sleep of 10ms and again for a sleep of 500ms. - Write a program that starts three goroutines, each looping and checking
ctx.Done()in aselectwith adefaultcase. Cancel the shared context frommainafter a short delay, and use async.WaitGroupto confirm all three goroutines actually exit. - Build a context tree with one parent and two independently-created children (each from
context.WithCancel(parent)). Cancel only one child, then printErr()for both children and the parent to confirm the sibling and the parent are unaffected.
Summary
context.Contextcarries cancellation signals, deadlines, and small request-scoped values across function and goroutine boundaries.- Contexts form a tree:
WithCancel,WithTimeout, andWithDeadlineeach derive a child from a parent; cancelling a parent cancels its entire subtree, never the reverse. Done()returns a channel that is closed, not sent to, on cancellation, so every goroutine selecting on it wakes at once;Err()then reportscontext.Canceledorcontext.DeadlineExceeded.- Always call the
cancelfunction you get back from aWith*constructor, usually viadefer, to release resources immediately instead of waiting for a timer to fire. - Use
WithValueonly for request-scoped metadata crossing API boundaries, with an unexported key type — never as a substitute for explicit function parameters. - Pass
ctxexplicitly as the first parameter of functions that need it; never store it in a struct.
