What does (*http.Request).WithContext return, and why must middleware pass on that copy?
answer
- the method hands something back
- the original request is untouched
- a shallow copy, not a mutation
- one assignment before next.ServeHTTP
- dropped result compiles, fails silently
basics
~20 sWithContext returns a shallow copy of the request carrying the new context and leaves the original untouched. Middleware must reassign r = r.WithContext(ctx) and hand that copy to next.ServeHTTP, or the inner handler still sees the old context.
solid answer
~40 sA `*http.Request` holds its context in an unexported field, and the only way to change it is `r.WithContext(ctx)`, which allocates a new `Request`, copies every field across, sets the new context on the copy and returns it. The receiver is not modified, so calling `r.WithContext(ctx)` as a bare statement and then `next.ServeHTTP(w, r)` compiles fine and silently does nothing — the inner handler reads the original context and finds no tenant id. The fix is one character of assignment: `r = r.WithContext(ctx)` before `next.ServeHTTP(w, r)`. Two details worth knowing: `WithContext` panics if you pass a nil context, and `r.Context()` is documented never to return nil, so a missing value shows up as a nil `Value` result rather than a nil-context crash.
code
go · 9 linestype tenantKey struct{}
func withTenant(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), tenantKey{}, "acme")
r.WithContext(ctx) // BUG: result thrown away
next.ServeHTTP(w, r) // still the original request
})
}go deeper
Be ready to write the four-line middleware from memory and say out loud that WithContext returns a copy, so you must assign it back to r before calling next.ServeHTTP.
Explain the mechanics: an unexported context field, a shallow struct copy, a legal statement-call that discards the result, and a nil context causing a panic.
Show how you would diagnose it in production — an inner handler logging an empty tenant id while the middleware clearly ran — and name the shallow-copy consequence that pushes you to Clone when headers change.
Frame it as an API-design tradeoff: immutable request and context make outer frames safe to reason about, at the price of a silent failure when a caller forgets to thread the copy through, so the team wants a lint or a review habit rather than trust.
## The field you cannot assign An `*http.Request` carries its `context.Context` in an unexported field. You can read it with `r.Context()` — documented to always return non-nil, defaulting to the background context — but you cannot write `r.ctx = ...` from outside `net/http`. The supported way to attach a context is: ```go func (r *Request) WithContext(ctx context.Context) *Request ``` Its implementation is short: it panics if `ctx` is nil, allocates a fresh `Request`, does `*r2 = *r` to copy every field, overwrites the context on the copy, and returns `r2`. Two consequences follow directly, and interviewers probe both. **It returns a value; it does not mutate.** The request you were handed is exactly as it was. If you drop the result, nothing you attached exists anywhere your chain can reach. **The copy is shallow.** `*r2 = *r` copies the struct fields, which means the copy shares the same `Header` map, the same `*url.URL`, the same `Body` reader and the same form values as the original. Only the context differs. That is fine when all you are doing is attaching a value; it matters the moment you also want to modify headers, which is what `(*http.Request).Clone` exists for. ## The bug this produces in a multi-tenant service A middleware resolves the tenant for each request and attaches it so downstream handlers can scope their queries: ```go type tenantKey struct{} func withTenant(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), tenantKey{}, resolveTenant(r)) r.WithContext(ctx) // result discarded next.ServeHTTP(w, r) // the ORIGINAL request }) } ``` Go lets a call stand alone as a statement, so discarding the returned `*Request` is legal code. There is no compiler error and no standard `go vet` check that flags it. Everything builds, the middleware runs, and the inner handler logs `tenant=""` because `r.Context().Value(tenantKey{})` walks a chain that never had the key and returns nil, which a comma-ok string assertion turns into the empty string. In a multi-tenant service that empty string is dangerous rather than merely wrong: a query scoped to `""` either returns nothing or, if some layer treats empty as "all", returns another tenant's rows. The symptom that reaches the engineer on call is a cross-tenant data mix-up; the cause is one missing assignment. The correct middleware differs by six characters: ```go r = r.WithContext(ctx) next.ServeHTTP(w, r) ``` Or, avoiding the shadowing question entirely, `next.ServeHTTP(w, r.WithContext(ctx))`. ## Why the design is a copy rather than a setter A `context.Context` is immutable by design: every derivation — `WithValue`, `WithCancel`, `WithTimeout` — produces a new context that points at its parent. Values flow downward from parent to child and are never inserted into an existing context. Making the request follow the same rule keeps the two consistent: an outer frame's request and context are stable no matter what the frames below it derive. It also keeps the handler chain safe to reason about, because a middleware cannot reach back and alter the request an outer middleware still holds and will use after `next.ServeHTTP` returns. The cost of that design is exactly the mistake above: the caller has to thread the new value through by hand, and forgetting to do so fails silently instead of loudly. ## Practical points - Reassign the local variable (`r = r.WithContext(ctx)`) at the top of your handler func, before any code that might use `r`, so there is no window in which some lines see the old request and some the new one. - Do the same in a handler, not just in middleware — attaching a deadline-free value or deriving a context inside a handler and then passing the old `r` to a helper has the identical failure mode. - Never pass nil: `r.WithContext(nil)` panics. If you have no context of your own, derive from `r.Context()`. - If the middleware also needs to change headers or the URL for the downstream handler, `WithContext` is the wrong tool, because the shallow copy shares those maps with the caller's request; `Clone` deep-copies them. - Keep whatever you attach request-scoped. A value that belongs to one request must live on that request's context and nowhere longer-lived, or two concurrent requests will read each other's data.
- What happens if you call r.WithContext(nil)?It panics with "nil context". The method requires a non-nil context, so derive from `r.Context()` — which is documented never to be nil for a request you were handed — rather than passing nil or a zero-value context variable.
- Does the compiler or go vet catch a discarded r.WithContext(ctx) result?No. A method call is a legal statement in Go, so discarding its result compiles cleanly, and no standard `go vet` analyzer flags it. It surfaces only downstream as a missing value — an empty tenant id in a log line — which is why the reassignment habit matters.
- Is the copy WithContext returns independent of the original request?Only the context is. `WithContext` does a shallow struct copy, so the copy shares the same `Header` map, `*url.URL` and `Body` as the original; writing a header through one is visible through the other. Use `(*http.Request).Clone` when you need those deep-copied.
It is like a photocopy with one line edited: writing on the copy changes nothing on the sheet you were given, so you have to hand the copy on.
saying these in an interview costs you the question
- Says WithContext mutates the request in place
- Calls r.WithContext(ctx) and passes the original r onward
- Thinks the value arrives anyway because Request is a pointer
- Believes the copy deep-copies the header map
- Claims the compiler or go vet catches the discarded result
- Assumes the context lives on the ResponseWriter