04 · The context Package¶
Module 1 ended with a bare select against
ctx.Done() and promised details later. This module covers context.Context
properly: deadlines, cancellation, and the narrow, debated case for passing
values through it — the mechanism Go uses to propagate "give up" across
API boundaries and goroutines.
Timeouts and cancellation¶
package main
import (
"context"
"errors"
"fmt"
"time"
)
func slowWork(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
fmt.Println("work finished")
return nil
case <-ctx.Done():
fmt.Println("work cancelled:", ctx.Err())
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := slowWork(ctx, 300*time.Millisecond)
fmt.Println("returned err:", err)
fmt.Println("is DeadlineExceeded:", errors.Is(err, context.DeadlineExceeded))
ctx2, cancel2 := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel2()
}()
err2 := slowWork(ctx2, 500*time.Millisecond)
fmt.Println("returned err2:", err2)
fmt.Println("is Canceled:", errors.Is(err2, context.Canceled))
}
$ go run .
work cancelled: context deadline exceeded
returned err: context deadline exceeded
is DeadlineExceeded: true
work cancelled: context canceled
returned err2: context canceled
is Canceled: true
slowWork never checks a boolean flag — it races time.After(d) against
ctx.Done() in a select, so whichever happens first wins. WithTimeout
produces context.DeadlineExceeded when the clock runs out; an explicit
cancel() call (as cancel2 shows) produces context.Canceled instead.
Both satisfy errors.Is against the respective sentinel, so callers can
distinguish "ran out of time" from "someone gave up" without string matching.
The trap: context.WithTimeout and context.WithCancel both return a
cancel function that must be called even if the context expires or is
cancelled naturally — defer cancel() immediately after creation. Skipping
it leaks the timer goroutine backing the context until the parent context
itself is done, which in a long-running server can be never.
Passing values (sparingly)¶
package main
import (
"context"
"fmt"
)
type ctxKey string
const requestIDKey ctxKey = "requestID"
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func requestID(ctx context.Context) string {
id, ok := ctx.Value(requestIDKey).(string)
if !ok {
return "unknown"
}
return id
}
func handle(ctx context.Context) {
fmt.Println("handling request:", requestID(ctx))
}
func main() {
ctx := withRequestID(context.Background(), "req-42")
handle(ctx)
handle(context.Background())
}
The unexported ctxKey type is deliberate — a plain string key would
collide with any other package's context.WithValue(ctx, "requestID", ...)
call using the same literal. Defining a private key type makes collisions
a compile-time impossibility for anyone outside the package.
context.Value is for request-scoped metadata that cuts across API
boundaries you don't control — request IDs, auth tokens, tracing spans. It
is not a substitute for passing an explicit parameter: if a function
needs a value to do its job, put it in the signature. Reaching into
ctx.Value for "regular" arguments makes the dependency invisible and
untyped.
Go-specific traps¶
- A cancelled parent context cancels every context derived from it —
WithTimeout/WithCancel/WithValueall build a tree; cancelling the root cancels the whole subtree, but cancelling a child never affects its parent or siblings. ctx.Err()isniluntilDone()fires — checking it before<-ctx.Done()has been observed to close is a race; use the channel, not a bareif ctx.Err() != nilpoll, unless you're deliberately sampling.context.Contextshould be the first parameter, namedctx, and never stored inside a struct — that's the convention every standard library API follows, and deviating from it surprises every caller.context.TODO()is not "safe to ignore" — it signals "this function should take a context but the plumbing isn't done yet"; leaving it in production code is a marker you forgot to finish the job.- Forgetting
defer cancel()— flagged above, but worth repeating:go vetcatches the common case (context.WithTimeoutresult's cancel func discarded entirely), but not the case where you call it conditionally on only some code paths.
How It Actually Works¶
A context.Context is an immutable, linked tree of small wrapper values — calling
context.WithCancel(parent) doesn't mutate the parent, it allocates a new
cancelCtx struct holding a pointer back to the parent plus its own done channel
and a slice of registered child contexts. Calling the returned cancel() function
closes that done channel (closing, not sending, because closing wakes every
goroutine selecting on it simultaneously) and then recursively cancels every
registered child — that recursive propagation is literal tree traversal over
pointers, not some broadcast mechanism. context.WithTimeout is WithCancel plus a
time.AfterFunc that calls cancel() when the timer fires, so a context timeout
and manual cancellation are the exact same code path underneath. context.
WithValue doesn't use a map — each call wraps the parent in a tiny struct holding
one key-value pair, so looking up a value walks up the linked list of these wrapper
structs comparing keys one at a time, which is why deeply nested WithValue chains
have O(depth) lookup cost and why the standard library warns against using context
values for anything beyond a few request-scoped items.
Cheat sheet¶
| Need | API |
|---|---|
| Root context | context.Background() (real programs) / context.TODO() (not yet wired up) |
| Cancel manually | ctx, cancel := context.WithCancel(parent) |
| Cancel after a duration | ctx, cancel := context.WithTimeout(parent, d) |
| Cancel at a wall-clock time | ctx, cancel := context.WithDeadline(parent, t) |
| Wait for cancellation | select { case <-ctx.Done(): ... } |
| Why it ended | ctx.Err() → context.Canceled or context.DeadlineExceeded |
| Attach request-scoped data | context.WithValue(ctx, privateKeyType, v) |
| Read it back | v, ok := ctx.Value(privateKeyType).(T) |
Related lessons¶
- The cancellable-send pattern this module formalizes: Module 1.
net/httphandlers get a per-request context viar.Context(), used the same way as here: Module 2.- The Level 3 project threads request
contexts into
database/sqlcalls so a client disconnect cancels the in-flight query.
Exercise¶
Write an HTTP handler that calls a simulated slow dependency (time.Sleep)
guarded by context.WithTimeout(r.Context(), 200*time.Millisecond). Return
503 Service Unavailable when the context's Done() fires before the
"dependency" finishes, and 200 OK otherwise. Test both paths with curl
by varying the simulated sleep duration above and below 200ms, and confirm
the response codes match.