1. WithValue: what it’s for and why it’s easy to misuse
When you first see context.WithValue, a very understandable thought comes up: “Oh, cool, I can stash anything in there — parameters, settings, my favorite music.” And this is where it’s important to stop. WithValue is not a just-in-case tote bag, but a small envelope for operation metadata: things related to the current request/operation that might be needed at different levels of the code.
The idea of context in Go is historically tied to servers: one incoming request triggers work across multiple functions and even goroutines, and all of them may need request-scoped data (for example, the request deadline, a request identifier, user information), and on cancellation they should finish quickly. That’s exactly how the authors describe it: context helps carry request-scoped values along with deadlines and cancellation signals across API boundaries.
The problem is that if you start using WithValue as a replacement for normal function arguments, the code turns into a quest: “where did they put this value?”, “why is this nil here?”, “why is the type wrong?”. So the goal is simple: learn to use WithValue strictly for its intended purpose and in a way that’s pleasant to read.
Context as an “envelope”: WithValue creates a new context
It’s important to approach WithValue without mystique. A context is an interface, and WithValue(parent, key, value) returns a new context derived from the parent. Nothing gets “stitched into” or “changed” in the parent. This matches the general model of derived contexts: contexts form a tree, and children inherit the parent’s cancellation/deadline behavior.
Imagine a folder with documents. parent is the folder, WithValue is “put one more note inside the folder,” but technically you end up with a new wrapper folder: it references the old folder and adds its note on top. The more WithValue calls you have, the more of these “covers” you get. That’s why we don’t want to put megabytes of data in there or call WithValue a thousand times in a loop — it’s not meant for that.
Mini example “the parent is not modified; a new context is created”:
package main
import (
"context"
"fmt"
)
type key struct{}
func main() {
parent := context.Background()
child := context.WithValue(parent, key{}, "hello")
fmt.Println(parent.Value(key{})) // <nil>
fmt.Println(child.Value(key{})) // hello
}
You can clearly see it here: parent has no value, child does.
2. Reading values and type-safe keys
ctx.Value: why it returns any
After WithValue, a mirror question appears: how do you retrieve values?
The Value(key) method returns any (in older texts you’ll see interface{}). This means: “I don’t know what’s stored under this key.” So you need to retrieve carefully, using a type assertion with ok. It’s the same technique you’ve already seen elsewhere in Go: we attempt to cast the value to the needed type and check ok so we don’t get a panic.
Mini example of extraction:
package main
import (
"context"
"fmt"
)
type userIDKey struct{}
func main() {
ctx := context.WithValue(context.Background(), userIDKey{}, 101)
v := ctx.Value(userIDKey{})
id, ok := v.(int)
fmt.Println(id, ok) // 101 true
}
Pay attention to the mechanics: first we get an any, then we ask “are you definitely an int?” If not, we get ok == false and decide what to do next.
Type-safe keys: why string is a “landmine”
If you write context.WithValue(ctx, "request_id", rid), it works. Sometimes for a long time. And then another package shows up in the project that also decided to store a value under the key "request_id" (or "id", or "user"), and you start playing “chess” with bugs: values overwrite each other, types diverge, part of the code sees one thing, part sees another.
That’s why in Go it’s customary to make the key not a string, but a separate type, most often an empty struct:
type requestIDKey struct{}
Why does this work? Because requestIDKey{} is a key that nobody will accidentally repeat as “just a string.” The key becomes unique by type, and the type becomes a kind of “namespace,” but at the compiler level.
One more important detail: context is safe for concurrent use across multiple goroutines. But that does not mean that any value inside automatically becomes thread-safe. The requirement is actually the opposite: if you put something into Value, that “something” must be safe to read in parallel and must not assume concurrent writes.
Making a human-friendly API: WithRequestID and RequestID
When you use a “key-type,” repetition appears in the code: in one place you store requestIDKey{}, in another you retrieve requestIDKey{}. Repetition itself isn’t scary, but it creates risk: somewhere you might accidentally use a different key or the wrong value type.
Good style is to hide the key and expose two helper functions: one stores, the other retrieves. Then external code says “store request id” and “get request id,” not “store a value under some weird key.”
An example of such a mini package (conventionally internal/requestctx).
“Store” function:
package requestctx
import "context"
type requestIDKey struct{}
func WithRequestID(ctx context.Context, rid string) context.Context {
return context.WithValue(ctx, requestIDKey{}, rid)
}
“Get” function (reuse the key instead of declaring it again):
package requestctx
import "context"
func RequestID(ctx context.Context) (string, bool) {
v := ctx.Value(requestIDKey{})
rid, ok := v.(string)
return rid, ok
}
The key doesn’t “leak” outside, and the API becomes readable and resistant to mistakes.
3. request_id in HTTP and logging
At the application boundary: HTTP middleware
When you have a request_id, it’s especially useful at the boundary of the application: in an HTTP server, in middleware, in logging. Then you can quickly match “this log” with “this request.” As a result, debugging starts to feel like an engineer’s job, not an archaeologist’s.
In Go’s server model, each incoming request is usually handled in its own goroutine, and inside the handler there may be calls to other systems and new goroutines. Context is designed precisely for these scenarios: request-scoped data, deadlines, and cancellation should be available to everyone involved in processing the request.
Mini example: we take X-Request-ID from the header and put it into ctx. Here we don’t generate “perfect UUIDs” to avoid distraction: if the header is missing, we set a simple placeholder (in a real project you’d generate a proper identifier).
package main
import (
"net/http"
"example/internal/requestctx"
)
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get("X-Request-ID")
if rid == "" {
rid = "unknown"
}
ctx := requestctx.WithRequestID(r.Context(), rid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Pay attention to the mechanics: we take r.Context(), call WithRequestID, then create a new *http.Request via r.WithContext(ctx).
Logging: when the benefit of WithValue becomes visible
As long as you’re just “putting a value into the context,” it can feel like a formality. The benefit becomes noticeable when you make a small logging function that automatically adds request_id if it exists.
And yes, this is that rare case where “hidden” information is actually appropriate: request_id is operation metadata, not a function’s business parameter. We pass business parameters explicitly, while request_id is like an order number in a kitchen: it’s important for the cook to see it, but it doesn’t change the recipe for the cutlet.
Example “log line with request_id”:
package main
import (
"context"
"fmt"
"example/internal/requestctx"
)
func logLine(ctx context.Context, msg string) {
if rid, ok := requestctx.RequestID(ctx); ok {
fmt.Println("request_id=", rid, msg) // request_id= abc-1 started
return
}
fmt.Println(msg) // started
}
This function isn’t a structured logger yet, but as a learning step it’s excellent: you can see why you were passing request_id around at all.
Diagram: how request_id “travels” through calls
Sometimes it’s useful to see the big picture so you stop thinking of WithValue as a weird language quirk. Below is a diagram of a single request: the boundary (HTTP) stores request_id, and after that the code mostly just reads it.
flowchart TD
A[HTTP request] --> B[Middleware: extracted X-Request-ID]
B --> C["ctx = WithRequestID(r.Context(), rid)"]
C --> D[Handler]
D --> E[Service/Usecase]
E --> F[Storage/Client]
D --> G[logLine: print with request_id]
Key idea: most layers do not create request_id; they only “respect the context” and read metadata when needed. That way WithValue stays simple instead of turning into chaos.
4. What you can put into context.Value
At this point you might want to “unleash your inner architect” and come up with 48 rules. But for beginners, a short and honest model is more useful: WithValue is for operation metadata, not for business data and not for application configuration.
Context is designed as a way to carry request-scoped values along with deadlines and cancellation signals. And those values must be safe for concurrent use.
Here’s a handy table (it doesn’t replace thinking, but it saves your nerves):
| Category | Example | Allowed in WithValue? | Why |
|---|---|---|---|
| Tracing/diagnostics | |
Yes | Operation metadata, useful “across” layers |
| Authentication/identification | user id, claims | Sometimes (carefully) | Also request metadata, but don’t turn context into a “safe” |
| Deadline/timeout | deadline inside ctx | Yes | That’s what context is for |
| Business parameters | |
No | They should be visible in the function signature |
| Large data | lists, API responses, files | No | Bloats memory and complicates data ownership |
| Application configuration | , “prod/dev mode” |
No | That’s not request-scoped; it’s process configuration |
The “sometimes” wording about user id means: if you already have an authentication layer and you need business logic to know “who is calling,” you can store minimal metadata (for example, UserID). But “store an access token and carry it everywhere” is almost always a bad idea.
5. WithValue anti-patterns
It’s very easy to cross the line and start doing this: instead of func Do(ctx, userID, taskID), write func Do(ctx) and pull everything out of the context. The first week it feels convenient, and then you catch a bug: taskID wasn’t stored, the value type changed, tests started depending on implicit state, and the new teammate quietly cries in go test.
The problem here isn’t that “context is bad.” The problem is that the function contract becomes implicit. The signature stops being honest. Instead of seeing “I need taskID,” you see “I need a context, and inside — guess for yourself.”
Another anti-pattern is string keys. It’s tricky because it doesn’t break for a long time, and then it breaks at the worst possible moment: when the project has grown and different packages started using similar key names.
And the final classic anti-pattern is putting a mutable object into context (for example, *bytes.Buffer or a map) and modifying it from multiple places. You can safely read context from multiple goroutines, but that does not automatically make your value thread-safe.
7. Common mistakes when working with WithValue
Mistake #1: using string keys (“request_id”, “user”, “id”).
At first it seems simpler and faster. Then a second package appears in the codebase that also decided "id" is a good key, and you get a fun competition of “who overwrites the value last.” Make keys a separate type, most often struct{}, and keep helper functions in one place.
Mistake #2: retrieving a value without checking the type (ctx.Value(k).(T) without ok).
This code works exactly until the first time the value is missing or unexpectedly of a different type. Then you get a panic in production and the feeling of “why me again.” The correct pattern is v, ok := ..., followed by careful handling.
Mistake #3: putting business parameters into context instead of explicit function arguments.
This turns your code into a “secret club”: to understand what a function needs, you have to read its internals and all calls leading up to it. Context is meant for request-scoped metadata and cancellation/deadline signals, not as a replacement for API design.
Mistake #4: storing mutable objects in context and changing them from different places.
You can safely read context from multiple goroutines, but that does not automatically make your value thread-safe. If you stored a map and someone writes to it in parallel, you’ll get a data race. The requirement is exactly the opposite: values in context must be safe for concurrent use.
Mistake #5: bloating context with large objects (“let’s put the entire API response in there”).
Technically you can, but you’re making memory ownership and the data lifecycle harder for yourself. WithValue is for small metadata; large data should live in normal variables, structs, and function return values.
GO TO FULL VERSION