The Context interface
Everything in the package builds on a single interface:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Deadline()returns the time at which this context will be automatically canceled, andokreports whether a deadline is set at all — a context with no deadline is not cancelable by time, though it may still be canceled explicitly.Done()returns a channel that is closed the moment the context is canceled or its deadline passes. A context that can never be canceled — like the ones returned byBackground()andTODO()— returns anilchannel fromDone(), and a receive on anilchannel blocks forever, which is exactly the behavior you want: aselecton it simply never fires.Err()reports whyDone()was closed. It returnsnilwhile the context is still active, and a non-nil error once it's done — see "ctx.Err() values" below for the two sentinel errors you'll actually see.Value(key)looks up a value associated with this context (or any of its ancestors) by key, returningnilif nothing is found.
CancelFunc
type CancelFunc func()
A CancelFunc tells the operation associated with a context to abandon its work. Calling it doesn't wait for that work to actually stop — it just signals cancellation by closing the Done() channel and setting Err() — so callers still need to let the canceled goroutines finish unwinding on their own. It's safe to call a CancelFunc more than once; the first call cancels, and every subsequent call is a no-op. It's also safe to call it concurrently from multiple goroutines.
Background() and TODO()
ctx := context.Background()
Background() returns a non-nil, empty Context that is never canceled, has no deadline, and carries no values. It's the root of every context tree, and it's what you use in main, in package initialization, in tests, and generally at the top level of a program where there's no parent context to derive from yet.
ctx := context.TODO()
TODO() returns the exact same thing as Background(), but it signals something different to the reader: "this function should eventually receive a context from its caller, but that plumbing hasn't been done yet" — or "I'm not yet sure which context to use here." It's a marker for future refactoring, not a functionally different type of context.
WithValue
func main() {
processID := "abc-xyz"
ctx := context.Background()
ctx = context.WithValue(ctx, "processID", processID)
ProcessRequest(ctx)
}
func ProcessRequest(ctx context.Context) {
value := ctx.Value("processID")
fmt.Printf("Processing ID: %v", value)
}
$ go run main.go
Processing ID: abc-xyz
WithValue returns a copy of the parent context that additionally carries the given key/value pair; looking up any other key still walks up to the parent. main attaches processID to the context, and by the time ProcessRequest calls ctx.Value("processID"), it gets the string back, so the program prints Processing ID: abc-xyz.
⚠️ Raw string keys are collision-prone:
context.WithValue(ctx, "processID", processID)uses the plain string"processID"as the key. Any other package that also happens to callctx.WithValue(ctx, "processID", somethingElse)will silently overwrite or shadow your value, because two identical string keys are indistinguishable toValue()regardless of which package set them.go vetandstaticcheckboth flag built-in types (string,int, etc.) used as context keys for exactly this reason. The fix is to define an unexported custom key type so the compiler enforces that only your package can construct a matching key:type ctxKey string
const processIDKey ctxKey = "processID"
ctx = context.WithValue(ctx, processIDKey, processID)
// later:
value := ctx.Value(processIDKey)Because
ctxKeyis a distinct type, a string literal"processID"from an unrelated package can never collide withprocessIDKey, even if the underlying text is identical. Also keep in mindWithValueis meant for request-scoped metadata like this, not for passing optional function parameters — see "values only for request-scoped metadata" below.
WithCancel
ctx, cancel := context.WithCancel(parent)
WithCancel returns a derived context along with a CancelFunc. Calling cancel() closes the derived context's Done() channel (and the Done() channel of every context derived from it), setting its Err() to context.Canceled. The convention is to call cancel as soon as the work it guards is complete — successfully or not — rather than holding onto it and passing it around to be called somewhere else later.
⚠️ Always
defer cancel(): every context returned byWithCancel,WithTimeout, orWithDeadlineholds resources internally — at minimum, a goroutine watching for the parent's cancellation and an entry in the parent's list of children to notify. If you never callcancel, those resources are retained until the parent context is itself canceled, which for a long-lived parent (like the base context of an HTTP server) may be never. The idiomatic fix is to calldefer cancel()immediately after creating the context, so it fires no matter how the enclosing function returns:ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always release resources, even on early return or panic
doWork(ctx)
WithDeadline and WithTimeout
ctx, cancel := context.WithDeadline(parent, someTime)
WithDeadline returns a context that is automatically canceled once the clock passes someTime, or earlier if cancel is called manually, or earlier still if the parent context is canceled first — whichever happens first wins.
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
WithTimeout is simply shorthand for WithDeadline(parent, time.Now().Add(timeout)) — it's the more common form in practice because most code thinks in terms of "how long should I wait" rather than "at what wall-clock time should this stop."
⚠️ Both of these also return a
CancelFunc, and the samedefer cancel()rule applies even though the context will eventually cancel itself once the deadline passes. Callingcancelearly — as soon as the guarded work finishes successfully, well before the deadline — stops the internal timer immediately instead of letting it keep running (and holding its resources) until the deadline arrives on its own.
Using context in an HTTP server
func handleRequest(w http.ResponseWriter, req *http.Request) {
fmt.Println("Handler started")
context := req.Context()
select {
case <-time.After(5 * time.Second):
fmt.Fprintf(w, "Response from the server")
case <-context.Done():
err := context.Err()
fmt.Println("Error:", err)
}
fmt.Println("Handler complete")
}
func main() {
http.HandleFunc("/request", handleRequest)
fmt.Println("Server is running...")
http.ListenAndServe(":4000", nil)
}
Every incoming *http.Request carries a context accessible through req.Context(), and the standard library automatically cancels it when the underlying client connection closes. The handler races two things in its select: a 5-second timer via time.After, and context.Done(). If a curl client waits out the full five seconds, time.After fires first and the handler writes "Response from the server". But if you interrupt the client midway — for example, hitting Ctrl+C on the curl command before it finishes — the connection closes, req.Context() is canceled, context.Done() fires first, and the server logs Error: context canceled instead. This pattern is exactly how you avoid doing expensive, resource-intensive work (database queries, downstream API calls) on behalf of a client that's already gone.
Advanced: context propagation rules
A handful of conventions make context usage predictable across a whole codebase, and following them is what lets unrelated packages compose without surprises:
- Context is always the first parameter, and by convention it's named
ctx:func FetchUser(ctx context.Context, id string) (*User, error) { ... }
- Never store a context inside a struct. It's tempting to save a request's context as a struct field so you don't have to keep threading it through function signatures, but that couples the struct's lifetime to a single request's cancellation and deadline, which usually isn't what you want if the struct outlives the request or is reused across requests. Pass
ctxexplicitly through every function call in the chain instead — it's the one exception Go's standard library and style guides make to "don't repeat yourself," precisely because implicit propagation would defeat the point of an explicit cancellation signal. - Derive, don't mutate. Every
With*function returns a new context; the parent is never modified. This is what makes contexts safe to share across goroutines — a child cancellation can never reach back and cancel a sibling or the parent.
Advanced: ctx.Err() values
Once Done() closes, ctx.Err() always returns one of exactly two sentinel errors, and checking which one told you why lets you branch on it with errors.Is:
context.Canceled— returned when the context was canceled by an explicit call to itsCancelFunc.context.DeadlineExceeded— returned when the context's deadline or timeout passed before it was canceled manually.
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
log.Println("operation timed out")
} else {
log.Println("operation canceled:", ctx.Err())
}
case result := <-resultCh:
use(result)
}
Advanced: WithTimeout for HTTP clients and database calls
Outbound calls are exactly where an unbounded wait can turn a single slow dependency into a cascading outage, so it's standard practice to wrap any external call with its own timeout:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", id)
Both net/http (via http.NewRequestWithContext) and database/sql (via the *Context method variants like QueryRowContext) accept a context specifically so a slow network call or a stuck query can be aborted instead of hanging the calling goroutine indefinitely.
Advanced: AfterFunc and WithoutCancel
Two additions from Go 1.21 are worth knowing about even if you reach for them rarely. context.AfterFunc(ctx, f) arranges to run f in its own goroutine as soon as ctx is done, which is useful for attaching cleanup logic to a context's cancellation without hand-rolling a select/Done() loop. context.WithoutCancel(ctx) returns a copy of ctx that still carries all its values but is detached from its parent's cancellation and deadline — useful for the rare case where you want to kick off background work (say, an audit log write) that should outlive the request that triggered it, while still carrying the request's trace ID or other metadata forward.
Advanced: values are for request-scoped metadata only
WithValue is deliberately limited to things like trace IDs, request IDs, or an authenticated user/session pulled from an auth header — data that describes the request itself and needs to reach deeply nested code without being threaded through every intermediate function signature. It's not a substitute for regular function parameters. If a value is required for a function to do its job correctly — not just "nice to have for logging" — pass it explicitly as a parameter. Values stashed in a context are invisible at the call site, untyped until you assert them, and easy to silently omit; a missing required parameter is a compile error, while a missing context value is often a nil you don't notice until production.
DUKE
Comments