skip to content

A handler panics dereferencing what ctx.Value returned because no middleware ran on that path — how do you make it safe?

level: seniorimportance: should knowfreq 40%

answer

  1. a nil interface, not a zero value
  2. comma-ok hides it, it does not fix it
  3. ask who else calls this code path
  4. one accessor per key, returning ok
  5. table test starting from context.Background()

basics

~20 s

ctx.Value returns a nil interface when nothing stored the key, so a bare assertion panics and a comma-ok assertion yields a nil pointer. Give every key one accessor returning a value plus an ok flag, and test the absent path.

solid answer

~50 s

The bug is an assumption about who calls the code. An HTTP middleware chain attaches the request metadata, so every handler path has it — then a queue consumer or a unit test calls the same service function with a fresh `context.Background()`, and the value is simply not there. `ctx.Value` returns a nil interface; a bare assertion panics with an interface-conversion error, and the comma-ok form silently yields a nil pointer that blows up one or two frames later, which is why the stack trace points somewhere innocent. The fix is structural: one accessor per key returning `(T, bool)` or a defined default, no assertions at call sites, and a table test over that accessor whose first case is `context.Background()`. If the code genuinely cannot run without the value, it does not belong in the context — make it a parameter, so the compiler catches the caller that forgot.

code

go · 11 lines
go
type reqIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, reqIDKey{}, id)
}

// RequestID reports the id and whether one was attached at all.
func RequestID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(reqIDKey{}).(string)
	return id, ok
}

go deeper

for a junior

Know the two spellings and what each does when the value is absent: a bare assertion panics immediately, a comma-ok assertion gives you the zero value — nil for a pointer — and no error. Always take the ok result.

for a middle

Explain why ctx.Value returns a nil interface rather than a typed zero, and show the accessor pattern that turns absence into a boolean the caller must handle instead of an assumption baked into every call site.

for a senior

Demonstrate the diagnosis: read the stack trace bottom-up to identify the entry point, find the single writer of the key, and recognise that a second caller has appeared. Then fix it structurally — accessor, seeded entry points, a table test over the missing case.

for a principal

Make the rule explicit for the codebase: context values are optional by construction, anything mandatory goes in the signature, and every key ships with an accessor whose missing-value behaviour is documented and tested.

## The failure A middleware chain in front of the HTTP handlers attaches request-scoped metadata — a trace id, a request id, the caller's identity — with `context.WithValue`. Deeper code reads it back. Everything works for months, because every request really does pass through the middleware. Then the same service function is called from somewhere else: a queue consumer, a cron-style job, an admin CLI, a new test. That caller starts from `context.Background()`. Nothing attached the key. The service panics. ## Why it panics where it does `ctx.Value(k)` returns `any`. If nothing in the chain holds `k`, it returns a **nil interface** — not a zero-valued `T`, and not an error. Two spellings fail differently: ```go s := ctx.Value(sessionKey{}).(*Session) // panics here: interface conversion, interface is nil s, _ := ctx.Value(sessionKey{}).(*Session) // no panic here: s is a nil *Session ``` The second form is the nastier one, and it is the form most people write because they were told to "use comma-ok". Comma-ok stops the *assertion* from panicking; it does not conjure a value. You get the zero value of the asserted type, which for a pointer is `nil`, and the program continues until something dereferences it. The resulting `invalid memory address or nil pointer dereference` names a line that has nothing to do with the missing value, which is why this costs real debugging time. ## Diagnosing it Read the panic's stack trace from the bottom up rather than the top down. The top frame is where the nil was used; the interesting part is the **entry point** at the bottom. When it is not the HTTP server's goroutine — it is the queue consumer's loop, or a test function — you have your answer immediately: this code path never ran the middleware. From there, grep for every place that key is written, and you usually find exactly one, sitting in the middleware. ## The fix, in layers **1. One accessor per key.** Never assert on `ctx.Value` at a call site. The package that owns the key exports a pair: ```go func WithRequestID(ctx context.Context, id string) context.Context func RequestID(ctx context.Context) (string, bool) ``` Now the missing case is a value in the type system — a `bool` a caller must look at — rather than an implicit assumption. If a sensible default exists (an empty trace id, a fresh generated id), the accessor may return it instead, but that decision is then made **once**, in a place with a name, not re-decided by every reader. **2. Decide what absence means, per key.** For a trace id, absence is normal and the answer is to generate one at the boundary or emit the record without correlation. For something the code truly cannot proceed without, absence must not be possible — which means the value belongs in the function signature, not in the context. **3. Seed every entry point through the same helper.** If HTTP requests, queue messages and scheduled jobs all end up in the same service code, each of those entry points needs its own small piece of setup calling the same `With...` helper. One writer, several callers. **4. Test the absent path.** A table test over the accessor with a `context.Background()` case, plus at least one test that calls the service function directly rather than through the middleware, catches this class permanently. Testing only end-to-end through the middleware chain is exactly what hid the bug. ## What to say about it in review The review rule that prevents recurrence is short: a context value is **optional by construction**. Any code that reads one must have a defined behaviour when it is absent, because you cannot see, from the signature, which callers will supply it — and next year there will be a caller you have not thought of.

  • What should the accessor do when the value is absent?
    Report it. Return `(zero, false)` and let the caller decide, or return a defined default when one genuinely exists — an empty trace id, or a freshly generated one at the boundary. What it must not do is hand back a nil pointer as if it were a value. Making that decision once, inside a named function, is the whole point of the accessor.
  • How do you keep several entry points consistent about attaching the value?
    Have exactly one function that writes each key, and call it from every entry point: the HTTP middleware, the queue consumer's per-message setup, the job runner, and a test helper. Reviewers can then check one small list of writers instead of auditing every `context.WithValue` call in the tree.
  • The stack trace points at a line that only reads a struct field. How do you get from there to the real cause?
    Read the trace bottom-up to find the goroutine's entry point. If it is a consumer loop or a test rather than the HTTP server, the code path skipped whatever attaches the value. Then find the single place the key is written and check whether that path reaches it — usually it does not, and the nil pointer came from a comma-ok assertion several frames up.

saying these in an interview costs you the question

  • Assumes every caller reaches the code through the HTTP middleware
  • Uses a bare type assertion on ctx.Value with no ok result
  • Thinks comma-ok makes the resulting nil pointer safe to use
  • Fixes it with a package-level default variable instead
  • Tests only through the middleware chain, never from context.Background()