skip to content

Why should a request's tracing span be carried in context.Context rather than stored in a struct field?

level: juniorimportance: should knowfreq 48%

answer

  1. one service value, many requests at once
  2. who owns the span: request or service?
  3. a field is shared; a parameter is not
  4. no goroutine-local slot to hide it in
  5. ctx is the parameter every layer already takes

basics

~20 s

A span belongs to one request, but a service struct is shared by every concurrent request, so a field is raced on and overwritten. context.Context is per request and flows down to exactly that request's callees.

solid answer

~40 s

A span describes one operation inside one request, so it has to reach the callees of that request and nobody else. An `http.Handler` value is registered once and serves every request concurrently, so a `span` field on it is written by many goroutines at once: that is a data race, and even without the race the last writer wins, so helpers read somebody else's span. Go has no goroutine-local storage to hide it in either, so the span has to be passed explicitly. `context.Context` is the parameter every layer already accepts, it is immutable so deriving a child span returns a new ctx instead of mutating the caller's, and handing that ctx to a goroutine carries the span with it. The same reasoning is why a ctx never belongs in a struct field.

code

go · 15 lines
go
type spanKey struct{}

type Service struct {
	span *span // wrong: one field, shared by every in-flight request
}

func (s *Service) HandleBad(w http.ResponseWriter, r *http.Request) {
	s.span = newSpan("handle") // raced on, and overwritten by the next request
	s.load(r.Context())
}

func (s *Service) HandleGood(w http.ResponseWriter, r *http.Request) {
	ctx := context.WithValue(r.Context(), spanKey{}, newSpan("handle"))
	s.load(ctx) // reaches this request's callees and no others
}

go deeper

for a junior

Be ready to say that a span belongs to one request while a handler or service value is shared by all of them, and that ctx is created per request and passed down. Recognising the data race in a shared field is the point.

for a middle

Explain the mechanics: ctx is immutable, so deriving a child returns a new ctx that only the callees you hand it to can see, and Go has no goroutine-local storage, so the value must travel as a parameter.

for a senior

Show the failure you have actually seen: a locked field removes the race but still attributes one request's work to another's span, and a caller that drops the returned ctx silently reparents everything below it.

for a principal

Own the convention itself. Decide what may ride in a ctx at all, keep it to request-scoped diagnostics rather than dependencies, and make sure the tracing helpers your teams share return a new ctx instead of mutating anything.

## What a span is, and what the problem actually is A span is a record of one timed unit of work — a handler, a query, an outbound call — that names its parent so the pieces can be assembled into one tree per request. The interesting design question is not how a span is created but **where a callee finds the span it should hang itself under**. Everything else follows from that. There are only four places a Go program can put such a value: 1. a package-level variable, 2. a field on a long-lived struct, 3. an ambient per-goroutine slot, 4. an explicit parameter. Option 3 does not exist in Go. There is deliberately no goroutine-local storage and no supported goroutine-id API, so there is nowhere ambient to stash "the current span". That leaves the value being passed, or being shared — and sharing is the trap. ## Why a struct field (or a package variable) is wrong An `http.Handler` — or the service struct it calls — is constructed once at start-up and then serves every request. If it carries a `span` field: - **It is a data race.** Two requests in flight means two goroutines writing the same field with no synchronisation. `go test -race` or a `-race` build will flag it, and an unsynchronised write is undefined behaviour, not merely a stale read. - **Even correctly locked, it is still wrong.** A mutex removes the race but not the aliasing: request B overwrites the field while request A's helpers are still running, so A's database call is recorded as a child of B's span. The trace becomes confidently wrong, which is worse than empty. - **It does not scale down either.** One handler struct per request would fix the sharing, but then every dependency the handler holds — clients, pools, caches — has to be rebuilt per request too. A package-level variable is the same defect with a wider blast radius. ## Why context.Context is the right slot `context.Context` is per request by construction: the server creates one for each request, and `r.Context()` hands it to the handler. Three properties make it the natural carrier: - **It is already threaded.** Anything doing I/O or blocking work already takes a ctx as its first parameter, so the span rides along on a parameter you were passing anyway. A separate `*Span` parameter would mean changing every signature in the chain, and would still not cross a boundary whose signature you do not own. - **It is immutable.** You never mutate a ctx; you derive a new one. Starting a child span produces a *new* ctx, and only the callees you hand it to see that child. The caller's own ctx — and therefore its own span — is untouched when the callee returns. That is exactly the scoping a span tree needs. - **It crosses into goroutines.** Start a goroutine for the request and pass it the ctx, and the span goes with it; nothing else about the goroutine has to know tracing exists. ## The costs you should be able to name Carrying values in a ctx is not free, and an interviewer may probe it. Lookups walk the derivation chain linearly, so a deep chain means a longer walk. The value is stored as `any`, so retrieval is a type assertion and the compiler cannot tell you the span is missing — absence shows up as a nil or a no-op span at runtime, not as a build error. That is why the convention is narrow: request-scoped diagnostics such as spans and request ids belong in a ctx; ordinary parameters and dependencies do not. One idiom follows directly from immutability. When a helper starts a child span it returns the new ctx: ctx, sp := startSpan(ctx, "load") If a caller ignores that returned ctx and keeps passing the old one, the code compiles, runs, and silently parents every deeper span to the wrong ancestor. Using the returned ctx is the whole discipline. ## The mirror rule Because the span rides in the ctx, and the ctx is per request, the ctx itself must not be parked in a struct field for later — that would reintroduce the exact sharing problem, with the added hazard of a stale, already-finished request scope. Pass it down; do not store it. ## What a strong answer sounds like "The span is request scoped and the service value is process scoped, so a field is both a race and an attribution bug. Go has no ambient per-goroutine slot, so it has to be a parameter, and ctx is the parameter that is already on every signature and that derives immutably, which is what a span tree needs."

  • Why not add a *Span parameter to every function instead of using ctx?
    It works inside code you own, but you have to change every signature down the chain, and it stops at any boundary whose signature you do not control — an interface a library calls back into, a handler shape, a callback. Those already take a ctx. And the next request-scoped value you need means another parameter, while ctx is one slot that carries them all.
  • If two goroutines for the same request share a ctx, do they share the same span value?
    Yes. Context values are not copied per goroutine; both goroutines get the same span object. So either the span implementation must be safe for concurrent use, or each goroutine should derive its own child span from the ctx it was handed and record into that.
  • Is it ever right to keep a span on a struct?
    On a per-operation struct, yes — an object that exists only for one unit of work, created and discarded inside it, can hold its own span. The rule is about long-lived, shared values: anything constructed at start-up and reused across requests must not hold request-scoped state.

A struct field is a whiteboard bolted to the wall of a shared office; ctx is the note you hand to whoever you ask for help. Everyone can scribble on the whiteboard at once, and nobody can tell whose numbers are whose.

saying these in an interview costs you the question

  • Storing the current span on a shared service struct so helpers can read it
  • Keeping the active span in a package-level variable
  • Claiming context.Context is mutable so the span can be attached later
  • Assuming Go has thread-local or goroutine-local storage for this
  • Ignoring the ctx returned when a child span is started