context.Context is Go's answer to a hard problem: carrying deadlines,
cancellation signals and request-scoped values across API boundaries — and
only API boundaries. After several years of running it in production, these
are the patterns we actually rely on, and the traps that still catch new
team members.
Featured article note. This one exists only in English — which is
exactly the case the translation convention handles: no locale switcher,
no fr hreflang, no broken links.
1. The function signature is a contract
The first parameter rule — ctx first — is not style. It makes the
dependency visible at every call site:
func (s *Store) User(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
row := s.pool.QueryRow(ctx,
`SELECT id, email FROM users WHERE id = $1`, id)
// ...
}
Two things happen here every time: the timeout composes with whatever
deadline the caller already set (never overrides it), and defer cancel()
runs the moment User returns — no leaked timers.
Never store a context in a struct. A struct outlives a request; a
context must not. If a method needs one, it takes ctx as its first
argument. Linters enforce this (containedctx) — keep them enabled.
2. Values: one private key type
Request-scoped values (trace IDs, auth principals) work, but they erase type safety at the boundary. The antidote is an unexported key type, so only your package can set or read the value:
package ctxutil
type principalKey struct{}
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey{}, p)
}
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey{}).(Principal)
return p, ok
}
Signals vs. values — where each belongs
| Cancellation & deadlines | Request-scoped values | |
|---|---|---|
| Mechanism | context.WithCancel / WithTimeout | context.WithValue |
| Crosses packages? | Yes — every layer participates | Preferably not — one owner package |
| Review question | "What happens when this is cancelled?" | "Could this be an explicit parameter?" |
When a value wants to become a parameter, let it. A value read in three places is fine; a value read in ten is a hidden function argument with no signature.
3. The traps
Background context at the top of the call stack
Ignoring the error from ctx.Err()
Assuming cancellation is instant
context.Background() belongs in main and tests. Anything else starting
a fresh root context silently severs the request's deadline and tracing.
When a worker exits because its context was cancelled, log
ctx.Err(). A goroutine that silently disappears is a debugging session
in production waiting to happen.
ctx.Done() closes when the parent says so — but the goroutine still has
to notice. Respect <-ctx.Done() in long loops, and close resources in
defers, not after the loop.
The short version
Pass ctx as the first parameter. Compose timeouts inward, cancel on
every return. Keep values behind one private key type per concern, and
promote values to parameters the moment they become load-bearing. The
context package is small on purpose — the discipline is the API.
One rule to remember. If you are typing context.Background() outside
main or a test — stop. Whatever you are building wants the request's
context.