- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a handler in a Go service you didn't write. It needs the
current user's ID. The signature is func(w http.ResponseWriter, r. No user in sight. You search the file. Nothing.
*http.Request)
You search the package. You find it:
userID := r.Context().Value("user_id").(string)
The user ID was placed in the request context by a middleware three
files away, keyed by a bare string, retrieved with a type assertion
that will panic the day someone changes the key. Nothing in the
handler's signature told you the value was there. Nothing stops a
different middleware from writing "user_id" with an int. The
data moved through the function the way contraband moves through a
border: hidden, undeclared, and your problem the moment it's
discovered.
The Go standard library documentation says it plainly. From the
context package docs: "Use context Values only for request-scoped
data that transits processes and APIs, not for passing optional
parameters to functions." Most codebases read the first half and
ignore the second.
context.Value is a map[any]any with a nice name
Strip away the propagation machinery and context.WithValue gives
you one thing: a key-value store threaded through every call, keyed
by any, valued by any. That is a global variable scoped to a
request. It has every property that makes globals hard to reason
about.
You can't see what's in it from a function signature. You can't tell
at compile time whether a key is present. You can't tell what type
comes back without an assertion. Two packages can write the same key
and clobber each other. A typo in the key string is a runtime
nil, not a compile error.
ctx = context.WithValue(ctx, "user_id", "u-123")
// ... 12 stack frames later ...
id, ok := ctx.Value("user_id").(string)
if !ok {
// was it never set? wrong type? wrong key? you can't tell.
}
When you put domain data here, you've made every function that reads
it depend on something invisible. A new reader of the code has to
trace the entire call chain to learn what the context carries. That
is the cost of a hidden global, and you pay it on every read.
The bare-string key is a collision waiting to happen
The example above uses "user_id" as the key. Two problems. First,
any other package can use the same string and overwrite your value.
Second, the standard library, your framework, and a third-party
middleware are all reaching into the same any-keyed namespace.
Go's own docs warn about this and give the fix: define a private key
type so the key is unique to your package and unforgeable from
outside.
package auth
type ctxKey int
const userKey ctxKey = 0
func WithUser(ctx context.Context, u User) context.Context {
return context.WithValue(ctx, userKey, u)
}
func UserFrom(ctx context.Context) (User, bool) {
u, ok := ctx.Value(userKey).(User)
return u, ok
}
ctxKey is unexported. No other package can construct a userKey,
so no other package can read or overwrite your value by accident.
The accessor functions hide the assertion in one place and return a
typed result with an ok. Callers never touch ctx.Value directly.
This is the type-safe key pattern. If you must put something in a
context, do it this way. But notice what it doesn't fix: the value
is still invisible at the call site, still optional, still a runtime
lookup. The pattern makes the smuggling safe. It doesn't stop the
smuggling.
What actually belongs in context
The dividing line is the word "request-scoped." A value belongs in
context when it rides along with the request through layers that
have no business knowing about it, and when the intermediate code
doesn't read it, only passes it through.
Good candidates:
- A request ID or trace ID for correlating logs across services.
- A deadline or cancellation signal (the whole point of
context). - An authenticated principal that deep, generic infrastructure needs without every layer naming it in a signature.
The test: does the code between the setter and the reader care
about the value? For a trace ID, no. The HTTP layer sets it, the
logging layer reads it, and the 20 functions in between pass ctx
along without ever touching the ID. Threading a traceID string
parameter through all of them would be noise. That is what context
is for.
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
traceID := tracing.FromContext(ctx) // set by middleware
log.Printf("trace=%s handling request", traceID)
// business code below takes ctx, never the traceID itself
}
What belongs in an explicit parameter
If a function needs a value to do its job, that value is an
argument. Put it in the signature. The compiler then guarantees the
caller provides it, the type is checked, and the next reader of the
code sees the dependency without tracing anything.
A user ID that your service layer needs to load an order is not
request-scoped plumbing. It's an input. Pass it.
// Smuggled: the dependency is invisible, the assertion can panic.
func (s *OrderService) List(ctx context.Context) ([]Order, error) {
uid := ctx.Value(userKey).(User).ID
return s.repo.ByUser(ctx, uid)
}
// Declared: the dependency is in the signature, checked at compile.
func (s *OrderService) List(
ctx context.Context, uid string,
) ([]Order, error) {
return s.repo.ByUser(ctx, uid)
}
The second version can't compile without a user ID. You can't forget
it. You can't pass the wrong type. A test can call it with a literal
string instead of building a fake context. The dependency is honest.
The middleware that authenticated the request still does its job:
it extracts the user from the context (where the auth library legit
put it) at the edge of your application, then calls into the service
with an explicit argument. Context at the boundary, parameters past
it.
func Handler(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFrom(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
orders, err := svc.List(r.Context(), user.ID)
// ...
}
The context carries the user across the HTTP boundary, where a
signature can't reach. The moment you're inside your own code, the
user ID becomes a normal argument. That is the whole rule.
A second tell: optional config in context
The other common smuggling case isn't auth, it's configuration.
Someone wants a function deep in the stack to behave differently, so
they stuff a flag into the context rather than change a signature.
ctx = context.WithValue(ctx, dryRunKey, true)
// ...
if dry, _ := ctx.Value(dryRunKey).(bool); dry {
return nil // skip the write
}
This is the "optional parameters to functions" the docs warn against,
word for word. The behavior of a write now depends on a value that
no signature mentions. Two callers set it differently and you debug
by reading the entire call chain. Make it a field on a request
struct, or an argument, or a method on a configured type. Anything
where the option is visible to the person calling the code.
Request scope, done right
Put the rule on a sticky note:
- Context carries values that transit code which doesn't read them (trace IDs, deadlines, the authenticated principal at the edge).
- Everything a function needs to do its job goes in the signature.
- If you do use context values, use a private key type and an accessor function. Never a bare string, never a raw assertion at the call site.
- The boundary between "context value" and "explicit parameter" is the edge of your application. Read from context once, at the handler, then pass typed arguments inward.
Three greps for the codebase you have right now:
-
.Value(with a string literal key. Each one is a collision risk and a missing type check. Convert to a private-key accessor or, if the value is really an input, hoist it into a parameter. -
ctx.Valuereads inside service or domain code, away from the HTTP edge. Most of those are inputs in disguise. Move them to the signature. -
WithValuecalls that carry feature flags or config. Those are optional parameters wearing a context costume. Pull them out.
context.Value isn't the enemy. It's a precise tool for a narrow
job: moving request-scoped data through code that has no reason to
name it. Use it for that and nothing else. The day you reach for it
to avoid editing a function signature is the day you've started
smuggling, and the next person to open the file is the one who pays
the duty.
If this was useful
The context package rewards a clear mental model and punishes a fuzzy
one. The Complete Guide to Go Programming works through context
from the ground up — propagation, deadlines, the value-key pattern,
and why the standard library drew the line where it did. Hexagonal
Architecture in Go takes it further, showing how keeping
request-scoped data at the boundary falls out naturally when your
ports and adapters are honest about their inputs.

Top comments (0)