skip to content

Context Values

WithValue attaches request-scoped data — a trace ID, the authenticated caller — under an unexported key type so no other package can collide with you. Interviewers use it to test judgment: smuggling required arguments through the context instead of the signature is the anti-pattern they are fishing for.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Why should a context.WithValue key be an unexported custom type rather than a plain string?

level: juniorimportance: must knowfreq 62%

answer

  1. one flat namespace, every package
  2. a plain string is not yours alone
  3. equality compares the dynamic type too
  4. no other package can name your type
  5. unexported key type plus exported helper funcs

basics

~20 s

Context values share one namespace across every package in a program. A key of an unexported type cannot be constructed or matched by any other package, so two libraries that both use the string "userID" cannot overwrite each other's value.

solid answer

~40 s

`context.WithValue` stores one key and one value per node, and `Value` matches keys with `==` on the interface, which compares the dynamic type as well as the data. A plain `"userID"` string therefore equals every other package's `"userID"`, so an unrelated library can read your value or shadow it, with no compiler error and no runtime warning. The fix is a key type nobody else can name: `type traceIDKey struct{}`, or `type ctxKey int` with unexported constants, declared unexported in your package. Because callers cannot construct the key, you also export a small pair of helpers — `WithTraceID(ctx, id)` and `TraceID(ctx) (string, bool)` — which is the real benefit: the value's type and its missing-value behaviour become part of a checked API instead of an assertion repeated at every call site.

code

go · 11 lines
go
// Unexported: no other package can construct this key, so it cannot collide.
type traceIDKey struct{}

func WithTraceID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, traceIDKey{}, id)
}

func TraceID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(traceIDKey{}).(string)
	return id, ok
}

go deeper

for a junior

Be ready to write the three lines from memory: an unexported key type, a With helper, and a getter that uses a comma-ok assertion. Say plainly that a string key can be produced by any other package and therefore collide.

for a middle

Explain the mechanism, not just the rule: keys are compared as interface values, so dynamic type participates in equality, and WithValue panics on a nil or non-comparable key. Mention that the unexported key is what forces callers through your accessors.

for a senior

Show the failure you are preventing — a silent shadow or leak that appears as a wrong value far from the code that set it — and argue for one package owning each key so there is a single place a value is written.

for a principal

Own the convention across teams: a shared internal package that defines the key types and the accessor pairs, so no service invents its own trace-id key and every reader gets the same defined behaviour when the value is absent.

## What a context value actually is `context.WithValue(parent, key, val)` does not modify anything. It returns a **new** `context.Context` that wraps the parent and holds exactly one key/value pair. `ctx.Value(k)` compares `k` against the pair this node holds and, if it does not match, asks the parent — up the chain to the root, where a miss returns `nil`. The comparison is `==` between two `any` (interface) values. Interface equality compares **both** the dynamic type and the underlying data. That single fact explains the whole convention. ## Why a string key is dangerous There is no registry, no namespacing and no ownership. Every package in the process writes into the same flat key space of one context chain. If your package does: ```go ctx = context.WithValue(ctx, "userID", id) ``` then any other package that writes `"userID"` produces a key that is `==` to yours. Two things can go wrong, both silently: - **Shadowing.** A middleware deeper in the chain stores its own `"userID"` and your lookup now returns theirs — a different type, or the same type with the wrong meaning. - **Leaking.** Code you do not control can read a value you meant to be internal to your package, and start depending on it. Neither produces a compile error, a panic, or a log line. It shows up much later as a wrong value or a failed type assertion. ## The pattern Declare a type only your package can name: ```go type traceIDKey struct{} // zero-size, comparable // or: type ctxKey int; const traceIDKey ctxKey = iota ``` An empty struct is the common choice: it is comparable, allocates nothing, and `traceIDKey{}` written twice inside your package produces two equal keys. An unexported integer-constant type is equally fine and is handy when one package owns several keys. Because the type is unexported, no other package can even write the expression that would build the key, so collision is impossible **by construction** rather than by convention. ## Export accessors, not keys The key being unexported means callers need helpers: ```go func WithTraceID(ctx context.Context, id string) context.Context func TraceID(ctx context.Context) (string, bool) ``` This is a feature. The pair documents what type the value has, gives exactly one place where the value is written, and forces a decision about the missing case at the API level rather than at each call site. ## What `WithValue` requires of a key The key must be non-nil and **comparable**; `WithValue` panics immediately otherwise. A slice, a map or a function value as a key panics rather than failing later. Structs made only of comparable fields are fine. Pointer keys work too — the standard library uses that form, exporting pointer values such as `http.ServerContextKey` when it deliberately wants other packages to read the value — but prefer a pointer to a **non-empty** struct if you take that route, since distinct zero-size allocations are not guaranteed to have distinct addresses. ## Common confusions - *"A package-level `var userKey = \"userID\"` fixes it."* It does not. The value is still a `string`, and the comparison is by type and data, not by which variable it came from. - *"Making the key unique enough is fine."* Uniqueness by hope is not uniqueness. A type is checked by the compiler; a string prefix is checked by nobody. - *"A struct key makes `Value` return a typed result."* It does not. `Value` returns `any` whatever the key is; the caller still asserts. The key protects the namespace, not the value's type — which is exactly why the exported accessor that does the assertion once is worth writing.

  • What does context.WithValue require of the key you hand it?
    It must be non-nil and comparable. `WithValue` checks this and panics on the spot, so a slice, map or function value as a key fails at the call, not later. Empty structs, unexported integer constants and pointers are all comparable and all conventional; the type just has to be one your package alone can name.
  • Is an exported context key ever the right choice?
    Only when you intend other packages to read the value — the standard library does this, exporting pointer keys such as `http.ServerContextKey` so servers and handlers can share state. Even then, prefer exporting an accessor function alongside it, so the value's type and the missing case are documented rather than rediscovered by every caller.
  • Would a package-level variable holding the string "userID" solve the collision problem?
    No. The key's identity is its dynamic type plus its data, not the variable it was read from. Two packages with separate `var` declarations of the same string still produce keys that compare equal. Only a distinct named type — unexported, so nobody else can write it — makes the keys different.

Two libraries writing "ID" on a crate with a marker pen will overwrite each other. A key of your own unexported type is a barcode no other package can print.

saying these in an interview costs you the question

  • Says a string key is fine if it is unique enough
  • Thinks ctx.Value compares keys by variable identity, not type and value
  • Exports the key type so any package can read the value
  • Believes context.WithValue detects or rejects duplicate keys
  • Uses a slice or map as a key and expects it to work
open as a page

How does ctx.Value locate a value, and what does that cost as the context chain grows?

level: middleimportance: should knowfreq 45%

basics

~20 s

Each context.WithValue call wraps the parent in a new node holding one key and value. Value checks its own key, then asks its parent — so a lookup is a linear walk up the chain, and a miss reaches the root.

open as a page

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%

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.

open as a page

Which request-scoped data may travel in a context.Context across teams, and how do you hold that line?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Admit only data that is about the request, immutable, and optional for whoever reads it — trace and request ids. Anything a function cannot run without belongs in its signature. Enforce it with one package owning the key types and accessors.

open as a page