skip to content

Why should an exported Go API take a `context.Context` per call rather than storing one in its struct?

level: middleimportance: must knowfreq 52%

answer

  1. one field, every call, one lifetime
  2. the signature stops advertising cancellation
  3. per-call deadlines become impossible
  4. the package docs state this as a rule
  5. a ctx field usually hides a go statement

basics

~10 s

A context describes the lifetime of one operation. Stored in a struct, every call shares one lifetime, per-call deadlines become impossible, and the signature stops telling callers the work is cancellable.

solid answer

~50 s

A `context.Context` carries the deadline and cancellation of a single operation, so it belongs to a call, not to an object. If a struct stores one, every method shares whatever lifetime was captured at construction — usually the long-lived one from `main`, which never cancels, or worse a request's context that outlives the request. Callers then cannot give one call a shorter timeout than another, and cannot cancel one call without killing the whole value. The signature stops advertising the fact that the work is cancellable at all, which is a real loss: `Fetch(ctx, id)` tells you at a glance that this call can be abandoned, while `Fetch(id)` looks like pure computation. The documented exception is a type that *is* one operation — `http.Request` carries a context, reached through `Request.Context` and `Request.WithContext` — and a long-lived component may take a context in a `Run` or `Start` that governs the component's own lifetime rather than individual calls.

code

go · 11 lines
go
type Client struct {
	ctx  context.Context // captured once, usually from main
	base string
}

func New(ctx context.Context, base string) *Client {
	return &Client{ctx: ctx, base: base}
}

// Looks like local work. Cannot be given its own deadline.
func (c *Client) Fetch(id string) ([]byte, error)

go deeper

for a junior

Recall the rule and the reason: a context belongs to one call, so it is passed as an argument, not kept as a field. Recognise that a method taking one may block and can be cancelled.

for a middle

Explain the concrete losses from a stored context: no per-call deadlines, cancellation that hits every call through the value, and a signature that no longer advertises that the work is abandonable. Know the http.Request exception and why it is consistent.

for a senior

Bring the operational angle: a stored context is usually captured from main and never cancels, so timeouts silently do not exist, and a stored request context on a shared value produces failures that look intermittent. Say what you look for in review.

for a principal

Decide the convention for a package other teams embed and hold the line on it, including where a Run or Start context legitimately governs a component's own lifetime. Recognise that removing a stored context later changes cancellation behaviour for every existing caller.

## The shape of the rule The standard library's own guidance is blunt: do not store a `context.Context` inside a struct type; pass it explicitly to each function that needs it, normally as the first parameter. It is one of the few pieces of API advice the standard library states as a rule rather than a suggestion, and understanding *why* matters more than reciting it. ## A context is a lifetime, and a lifetime belongs to an operation A `context.Context` is an immutable value carrying three things: a deadline, a cancellation signal, and request-scoped values. All three describe *this piece of work*: this RPC, this request, this batch. They do not describe an object, which may serve many pieces of work over its life. When a struct stores one, you have collapsed many lifetimes into one, and the consequences follow mechanically. ### Every call gets the same deadline A caller with a 50 ms budget for a cheap lookup and a 30 s budget for a bulk operation cannot express either, because both calls read the same field. In practice the stored context is the one available at construction — often `context.Background()` from `main`, which never cancels, so the cancellation machinery is present but inert. The API looks cancellable and is not. ### Cancelling one call cancels everything If a caller does manage to cancel the stored context, every in-flight and future call through that value fails. There is no way to abandon one operation. This is a particularly nasty failure when the value is shared between goroutines, which most clients are: one caller's timeout becomes everyone's outage. ### The signature stops telling the truth `func (c *Client) Fetch(id string) ([]byte, error)` reads like local work. `func (c *Client) Fetch(ctx context.Context, id string) ([]byte, error)` reads like I/O that can be abandoned. Reviewers, callers and tools all use the parameter as the signal. Hiding the context in a field removes the only clue at the call site. ### A stored context can outlive what it described The worst version is a request-scoped context stashed in a struct that survives the request. Either it cancels while the value is still in use — producing failures that look random — or something keeps a reference to it and the values it carries live far longer than intended. ### Testing gets harder Exercising a timeout for one call means building a value whose stored context is already near its deadline, and that value is then unusable for the rest of the test. Per-call contexts make each case independent. ## The exceptions, and why they are exceptions **A type that is itself one operation.** `http.Request` carries a context, exposed through `Request.Context` and set through `Request.WithContext`, which returns a shallow copy rather than mutating. That is consistent with the rule rather than a violation: a request *is* a single operation with a single lifetime, and the context is not shared across unrelated work. Note that even here, the field is unexported and reached through methods, so the type controls how it is replaced. **A component with its own lifetime.** A long-running watcher, poller or consumer may take a context in `Run(ctx) error` or `Start(ctx)`, where the context governs the component's whole life: cancel it and the component shuts down. This is legitimate, and it is different from storing a context to use as the default for every method call. If the same type also exposes per-call methods, those methods still take their own context. **Struct-shaped call parameters.** If a call takes a parameter struct, the context still goes in the signature, not in the struct — the standard idiom is `Do(ctx context.Context, req Request)`. ## How this connects to who starts goroutines The two decisions are the same decision seen from different sides. A package that takes a context per call is saying: you drive this, and you can stop it. A package that stores one at construction is usually a package that also started something at construction — and the stored context exists to give that background work a lifetime the caller can no longer see or influence. When you find a `ctx` field during review, look immediately for a `go` statement in the constructor; they travel together. ## What to say when the parameter feels repetitive Threading a context through many methods is verbose, and that verbosity is the usual motivation for hiding it in a field. The answer is that the parameter is the API: it is how a caller expresses a deadline, how cancellation reaches the bottom of the call stack, and how a reader knows a function may block. Verbosity is the price of an honest signature, and no shortcut has ever survived contact with a caller who needed two different timeouts.

  • Why does `http.Request` carry a context if the rule says not to store one?
    Because a request is itself a single operation with a single lifetime, so the context describes exactly the thing the struct represents. The field is unexported and reached through `Request.Context`, and replacing it goes through `Request.WithContext`, which returns a copy rather than mutating the original. That is the rule honoured, not broken.
  • Is it wrong for a long-running component to take a context in `Run(ctx)`?
    No. There the context governs the component's own lifetime: cancel it and the component shuts down. That is a deliberate, documented promise rather than a hidden default. The distinction is that it is not then reused as the context for unrelated per-call methods, which still take their own.
  • What goes wrong when a request-scoped context is stored on a value that outlives the request?
    Two failure modes. The context cancels while the value is still in use, so later calls fail for reasons that have nothing to do with them and look intermittent. Or a reference keeps it alive and everything it carries — request values, and whatever they retain — lives far longer than the request that created it.

saying these in an interview costs you the question

  • Stores a context in a struct to avoid repeating the parameter
  • Uses context.Background captured in a constructor as a default deadline
  • Thinks a context describes an object rather than one operation
  • Says per-call contexts are just boilerplate with no behavioural difference
  • Keeps a request-scoped context on a value that outlives the request
  • Puts the context inside a request parameter struct instead of the signature