Context
Cancellation, deadlines, propagation, and the values trap.
context.Context is the thread that runs through every serious Go program: cancellation, deadlines, and request-scoped data flowing across API boundaries and goroutines. It's also widely misused — passed where it shouldn't be, stuffed with values it shouldn't carry, or ignored where it matters most.
Let's get it right.
What context is for
A Context carries two things across function calls and goroutine boundaries:
- A cancellation signal — "stop what you're doing, the caller gave up."
- A deadline — "stop if you're not done by time T."
Plus, secondarily and more cautiously, request-scoped values. The signal is the heart of it. Everything else is supporting cast.
The interface is small:
type Context interface {
Done() <-chan struct{} // closed when cancelled or deadline hits
Err() error // why Done() was closed
Deadline() (time.Time, bool) // the deadline, if any
Value(key any) any // request-scoped lookup
}Done() returning a channel is the key design choice: it lets you select on cancellation alongside your real work, which is exactly what the concurrency patterns do.
The rules
These conventions are near-universal in Go code, and breaking them will get your PR comments:
- Pass
ctxas the first parameter, namedctx. Always:func Fetch(ctx context.Context, url string) (*Response, error). - Never store a Context in a struct. Pass it through the call chain. (Rare exceptions exist, but treat them as exceptions.)
- Don't pass
nil. If you don't have one, usecontext.TODO()as a placeholder while you figure out the plumbing, orcontext.Background()at the true top of your program. - Context flows down, never up. A function receives a context and passes derived contexts to its callees. It doesn't hand one back.
Deriving contexts
You never mutate a context; you derive a new one from a parent. The derived context is cancelled when its parent is — cancellation propagates down the tree.
// Cancellation you trigger manually.
ctx, cancel := context.WithCancel(parent)
defer cancel() // ALWAYS call cancel, even on the happy path — it frees resources.
// A hard deadline.
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
defer cancel()
// A timeout (deadline relative to now) — the most common one.
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()Call cancel every time, no exceptions. Even when the operation succeeds and even when a timeout will fire on its own. Skipping it leaks the timer and the internal goroutine until the parent is cancelled. defer cancel() on the line after creation is the habit to build — go vet will warn you if you forget.
Respecting cancellation
A context is only useful if your code actually checks it. Two ways:
Pass it to functions that already honor it. Most standard-library and well-behaved third-party calls take a context and return promptly when it's cancelled. This is the easy, preferred path:
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req) // aborts if ctx is cancelledSelect on Done() in your own loops. For work you control — especially long loops or blocking sends — check the context yourself:
func process(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err() // returns context.Canceled or context.DeadlineExceeded
default:
handle(item)
}
}
return nil
}Returning ctx.Err() is the convention: it surfaces why you stopped (cancelled vs. deadline) and plays well with errors.Is:
if errors.Is(err, context.DeadlineExceeded) {
// it timed out specifically
}A complete example
Tying it together — a timeout-bounded fetch that cleans up correctly:
func fetchWithTimeout(url string, timeout time.Duration) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
// On timeout this is wrapped DeadlineExceeded.
return nil, fmt.Errorf("fetching %s: %w", url, err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}If the server is slow, the context's deadline fires, Do returns, the body closes, and cancel releases the timer. No leaks, no hung goroutines.
The values trap
context.WithValue lets you attach request-scoped data — a request ID, a trace span, an authenticated user. It's genuinely useful and genuinely overused. The guidance:
- Use it for request-scoped, cross-cutting data that rides along with a request: trace IDs, auth tokens, deadlines set by middleware. Things every layer might need but none should have in its signature.
- Do NOT use it to pass function parameters. If a function needs a value to do its job, that value belongs in its signature, not smuggled through the context. Context values are invisible to the type system — the compiler can't tell you when one is missing.
- Use a private, typed key to avoid collisions across packages:
type ctxKey int
const userKey ctxKey = 0
func WithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userKey, u)
}
func UserFrom(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}Never use a plain string as the key — two packages could pick the same string and clobber each other. A private type makes the key un-forgeable from outside your package.
If removing the value would be a compile error somewhere, it should be a parameter. If removing it only degrades behavior (no trace ID, a default user), the context is a reasonable home.
The takeaway
Context propagates cancellation and deadlines (and, carefully, request-scoped values) across your call graph. Pass it first, named ctx; never store it in a struct; always defer cancel(); and actually respect it — by handing it to context-aware calls and by selecting on Done() in your own loops. Keep values to cross-cutting request data with private typed keys, and put real parameters in signatures where the compiler can see them.