IndexAdvanced Go PatternsPart 05

Context

Cancellation, deadlines, propagation, and the values trap.

Mar 20, 20265 min readConcurrencyPart 05 of 11

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:

  1. A cancellation signal — "stop what you're doing, the caller gave up."
  2. 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:

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

parent ctxcancel() called child ADone() closed child BDone() closed child CDone() closed grandchild B1 grandchild B2
Cancel the parent — Done() closes for every context in the subtree

The rules

These conventions are near-universal in Go code, and breaking them will get your PR comments:

  • Pass ctx as the first parameter, named ctx. 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, use context.TODO() as a placeholder while you figure out the plumbing, or context.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.

Go
// 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:

Go
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req) // aborts if ctx is cancelled

Select on Done() in your own loops. For work you control — especially long loops or blocking sends — check the context yourself:

Go
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:

Code
if errors.Is(err, context.DeadlineExceeded) {
    // it timed out specifically
}

A complete example

Tying it together — a timeout-bounded fetch that cleans up correctly:

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

5.0s left 3.8s left 1.2s left deadline handlerWithTimeout http clientsame ctx dialersame ctx
One deadline, shared down the call chain — the budget shrinks, the cutoff stays fixed

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:
Go
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.

Litmus test

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.