Skip to content
Go 1 min read

Go context patterns that scale

Cancellation, deadlines and request-scoped values — the context.Context patterns we rely on in production, and the traps we learned to avoid.

Ava MartinAva MartinStaff Engineer

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:

store/user.go
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:

ctxutil/principal.go
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 & deadlinesRequest-scoped values
Mechanismcontext.WithCancel / WithTimeoutcontext.WithValue
Crosses packages?Yes — every layer participatesPreferably 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

  1. Background context at the top of the call stack

  2. context.Background() belongs in main and tests. Anything else starting a fresh root context silently severs the request's deadline and tracing.

  3. Ignoring the error from ctx.Err()

  4. 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.

  5. Assuming cancellation is instant

  6. 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.

go concurrency patterns
Ava Martin

Written by

Ava Martin

Staff Engineer

Ava writes about Go performance, concurrency and the backend systems behind Notideus. Previously infrastructure at a large European cloud provider.