context.WithValue attaches request-scoped data to a context so it can travel through every function call in a request's lifecycle without being threaded as an explicit parameter. It fits trace IDs, user IDs, and request IDs that middleware needs to pass downstream, and it's the wrong choice for almost everything else. This lesson covers the mechanics, the custom-key pattern that prevents package collisions, and the rules for what belongs in a context value versus what should stay a regular function argument.
context.WithValue takes a parent context, a key, and a value, and returns a new context that carries the key-value pair on top of everything the parent already had.
Both key and val are typed as any, which is what makes the API flexible but also what makes it easy to misuse. To read the value back out, call ctx.Value(key), which returns an any that you have to type-assert into the concrete type you stored.
A first pass, written the way you shouldn't write it in real code:
The shape is simple: derive a new context from a parent, attach a key, then anyone holding the derived context (or any context derived further from it) can call Value to retrieve it. The problem with the code above is the key. We used a plain string, and that's the start of every WithValue mistake. The next section explains why.
context.Value looks up keys by equality. Two string keys with the same characters are equal, regardless of which package wrote them. That means if your package stores "requestID" and some imported library stores "requestID" too, your value silently overwrites theirs (or theirs silently shadows yours when read).
This is the kind of bug that survives code review because nothing looks wrong locally. Each package, on its own, does a reasonable thing. The collision only shows up when the two packages share a request.
Here's a tiny reproduction:
The authentication code put the string "u-001" into the context, then the logging code overwrote it with an int. By the time anyone reads "userID", the original is gone and the type assertion fails.
The fix is the custom-key pattern. Define an unexported type that lives inside your package, and use a single zero value of that type as the key. Because the type is unexported, no other package can even name it, so collisions are impossible by construction.
Even if another package defines its own type userIDKey struct{}, the two are different types from the runtime's perspective, so the lookups don't collide. This is the idiom you'll find in the standard library (look at net/http's ServerContextKey) and in every well-written Go service.
Cost: Using a plain string as a context key risks silent collisions between packages. Always wrap keys in an unexported named type so the type system enforces isolation.
There's a second flavor of this pattern using a custom int type:
Both styles work. struct{} is common when you have one key per concept and want each key to live next to the helpers that use it. The ctxKey int with iota is handy when one package owns several related keys. Pick one and be consistent.
Calling ctx.Value(...) and type-asserting at every call site is noisy and error-prone. The convention is to wrap both ends in small helper functions that live next to the key. The setter takes a context and a value and returns a new context. The getter takes a context and returns the value plus an ok boolean so callers don't have to type-assert.
A few things this pattern buys you:
string here), so callers can't accidentally store the wrong type.ok is false, the value isn't there or isn't the expected type.When you have several pieces of request-scoped data, define one helper pair per piece. Don't try to cram everything into a single "request info" struct in the context. Each piece has its own lifetime and its own callers, and combining them just makes the helpers harder to read.
The Go documentation is explicit about this, and it's worth quoting the spirit: context values should be limited to request-scoped data that transits process boundaries and APIs, not used as a general-purpose mechanism for passing optional parameters to functions.
In practice, that gives you a short list of values that belong:
| Belongs in context | Why |
|---|---|
| Trace IDs | Generated at the edge, needed by every span and log line downstream |
| Request IDs | Same as trace IDs, often shorter-lived |
| User IDs / auth tokens | Resolved once by middleware, read by handlers and services |
| Customer locale or currency | Per-request, decided early, read deep in the call stack |
| Deadline and cancellation signals | The primary job of context |
And a longer list of things that don't:
| Does NOT belong in context | Why |
|---|---|
| Database handles | These are dependencies. Pass them as struct fields or constructor args. |
| Loggers | Same. Wrap your handler in a struct that holds a *log.Logger. |
| Configuration values | Load at startup, hold in a struct, inject. |
| Optional function parameters | If a function needs an option, take it as an argument. |
| Anything you wouldn't want a downstream caller to forge | Context values are mutable from anyone's perspective: a child can shadow them. |
Here's the anti-pattern you should specifically avoid: smuggling a database handle through context to avoid threading it through function signatures.
The code runs. The output looks fine. The design is still bad. Three reasons:
loadProduct lies. Reading it, you can't tell that it needs a database. The dependency is invisible until the function runs and the type assertion fires.loadProduct you have to construct a context with the right key, not just pass a fake DB. The compiler can't help you remember.dbKey, every place that stuffs a DB into context has to be updated, and the failure mode is a runtime error rather than a compile error.The correct shape is to put the DB on a handler struct or service, and pass it explicitly:
The context is still here. It still carries deadlines and trace IDs. But the DB is where it belongs: a real, typed dependency on the service.
Every call to context.WithValue(parent, key, val) returns a small wrapper around the parent. The wrapper holds three things: the parent, the key, and the value. When you call Value(key) on it, the wrapper compares the requested key to its own key. If they match, it returns its value. If not, it delegates the call to the parent. That recursion continues all the way up the chain until something matches or you reach the root context, which returns nil.
The shape is a linked list, with the root (context.Background() or context.TODO()) at the bottom and the most recently derived context at the top.
A lookup for localeKey matches at the top wrapper and returns immediately. A lookup for traceIDKey walks two hops down before matching. A lookup for a key that nobody stored walks all the way to Background and gets nil.
This has two practical consequences. The first is that values added closer to the leaf shadow values with the same key added higher up:
The second consequence is the cost. Lookup is O(n) in the depth of the chain. Each WithValue adds one node. For typical request scopes with a handful of values, the chain is short and the cost is invisible. If you start stuffing dozens of unrelated keys into the same context, lookups slow down linearly, and you've also lost the discipline of "context is for request-scoped state".
Cost: ctx.Value walks the parent chain on every call, so lookup is O(depth). Don't use a context as a general key-value store. Keep the chain shallow and store unrelated data in dedicated structs.
If you're calling Value in a hot path, cache the result in a local variable at the top of the function instead of re-reading on every iteration:
Here's how the pieces fit together in a realistic shape. A request enters through a middleware that stamps a trace ID. The checkout flow then calls a series of services, each of which logs and (eventually) makes a downstream call. Every layer reads the trace ID without it appearing in any function signature except context.Context.
Notice what isn't in the function signatures. reserveStock and chargeCard take only ctx. They don't know there's a trace ID, but the logger reaches into the context and pulls it out. If tomorrow you add a tenant ID or a request ID, you add a new helper pair and stamp it in handleCheckout, and every downstream function picks it up automatically.
The flow through derived contexts looks like this:
reserveStock and chargeCard are at the bottom of the call stack but they hold a context that was derived all the way back at the middleware. Every Value lookup they make walks up that chain.
One last useful pattern: the customer's locale flowing into a recommendation call. Same idea, narrower scope.
The recommendation function never asks for a locale. It looks it up. That keeps its signature stable as more request-scoped data shows up over time.
9 quizzes