Context Values in Go and What Should Never Go Inside
When to use context.WithValue in Go: request IDs and identity, typed keys and accessors, collision traps, lookup cost, and what to keep out of the context.

context.WithValue is the part of the context package that people either overuse or avoid. Overuse turns the context into an untyped bag that every function reaches into, and the program's real dependencies disappear from its signatures. Avoidance means threading a request ID through forty function parameters or losing it from the logs. The right use is narrow and very useful: a few pieces of data that belong to the request and that need to travel through layers that should not know about each other.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article covers what belongs, how to write the keys and accessors, the traps, and the cost. It follows context explained and belongs to the concurrency material. Everything ran on Go 1.25.6.
What belongs in a context value
The package documentation says to use context values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions. Concretely, good candidates are:
- A request or correlation ID, so every log line a request produces can be tied together.
- Trace and span information, which tracing libraries store in the context so child spans find their parent.
- The authenticated principal, set by auth middleware and read by handlers and audit logging.
- Per-request logger fields or a locale, when many layers add to a log line.
What these have in common: they describe the request, not how to do the work. A function receiving a context without them still works, perhaps with less information in the logs. Contrast data a function cannot work without:
- A database handle, a cache client, a mailer: these are dependencies. Pass them as arguments or struct fields, where a missing one is a compile error or a clear constructor failure.
- Feature flags that change behaviour, page sizes, filters: these are parameters. Putting them in the context makes the function's behavior depend on invisible input.
- Anything you would document as "required: set X in the context before calling this".
A test for any candidate: if removing the value changes what the function returns (not only what it logs), it is a parameter, not a context value.
Typed keys, and why strings are dangerous
The key can be any comparable value, and the compiler does not stop you using a string. Two packages that both pick "user" as a key collide, and one silently overwrites or reads the other's data:
package main
import (
"context"
"fmt"
)
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, "user", "ada") // package A
ctx = context.WithValue(ctx, "user", "mallory") // package B, unrelated, same key
fmt.Println("package A reads:", ctx.Value("user"))
}package A reads: malloryPackage A stored ada and reads back mallory, since Value returns the most recently added match. Linters flag this (staticcheck's SA1029 reports built-in types as context keys). The fix is a key type that only your package can name. Since keys are compared by type and value, an unexported type makes collisions impossible across packages:
package main
import (
"context"
"fmt"
)
type ctxKey int // unexported: no other package can construct this type
const (
requestIDKey ctxKey = iota
userKey
)
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, requestIDKey, "req-7f3a")
ctx = context.WithValue(ctx, userKey, "ada")
// Another package using the string "user" cannot clash with userKey.
ctx = context.WithValue(ctx, "user", "mallory")
fmt.Println(ctx.Value(userKey), ctx.Value(requestIDKey))
}ada req-7f3aWith typed keys ada survives, because ctxKey(1) and the string "user" are different keys. (The remaining "user" string line is there to demonstrate the point; do not write it.)
Accessor functions
Never let callers use the key directly. Wrap WithValue and Value in typed functions in the package that owns the key:
package main
import (
"context"
"fmt"
)
type ctxKey struct{ name string }
var (
requestIDKey = ctxKey{"request-id"}
userKey = ctxKey{"user"}
)
// WithRequestID returns a context carrying the request ID.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
// RequestID returns the request ID, or "" if none was set.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}
// WithUser returns a context carrying the authenticated user's name.
func WithUser(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, userKey, name)
}
// User returns the user and whether one was set.
func User(ctx context.Context) (string, bool) {
name, ok := ctx.Value(userKey).(string)
return name, ok
}
func logLine(ctx context.Context, msg string) {
user, _ := User(ctx)
fmt.Printf("[%s] user=%q %s\n", RequestID(ctx), user, msg)
}
func main() {
ctx := WithRequestID(context.Background(), "req-7f3a")
logLine(ctx, "before auth")
ctx = WithUser(ctx, "ada")
logLine(ctx, "after auth")
}[req-7f3a] user="" before auth
[req-7f3a] user="ada" after authIn a real program the accessors live in their own small package (call it reqctx), and the key type stays unexported there. The accessors give you type safety (a string goes in and a string comes out), a place to document what the value means, and a missing-value policy: RequestID returns an empty string, User returns an ok so callers can tell "no user" from "empty name". Nobody outside the package can store a value under the key except through it. Use the comma-ok assertion inside the accessor so a missing or wrongly typed value does not panic (interface conversion). The logging helper reads the ID and user from the context without anyone passing them as arguments, which is what the mechanism is for, and it works without them, printing empty values, because nothing depends on their presence.
Immutability and visibility
Values in a context are immutable by convention and by structure: WithValue returns a new context, and the old one is unchanged. Two consequences. A value added in a handler is visible to the functions it calls, and invisible to the code that called the handler: you cannot use the context to send data back up. And the stored value itself should not be mutated after it is stored, especially if it is a pointer or a map, because the context is shared across goroutines and the value then needs its own synchronization. Store small immutable values: strings, ints, or pointers to structs that are never changed after creation.
What lookups cost
Value walks the chain from the current context toward the root, comparing keys, until it finds a match or reaches Background. A lookup for a value added early in a long chain visits every wrapper. A benchmark shows the cost is real and small:
package main
import (
"context"
"testing"
)
type key int
var sink any
func chain(depth int) context.Context {
ctx := context.WithValue(context.Background(), key(0), "target")
for i := 1; i <= depth; i++ {
ctx = context.WithValue(ctx, key(i), i)
}
return ctx
}
func benchLookup(b *testing.B, depth int) {
ctx := chain(depth)
for i := 0; i < b.N; i++ {
sink = ctx.Value(key(0)) // the deepest entry: the worst case
}
}
func BenchmarkDepth1(b *testing.B) { benchLookup(b, 1) }
func BenchmarkDepth10(b *testing.B) { benchLookup(b, 10) }
func BenchmarkDepth100(b *testing.B) { benchLookup(b, 100) }goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkDepth1-10 179614670 6.608 ns/op 0 B/op 0 allocs/op
BenchmarkDepth1-10 183309351 6.586 ns/op 0 B/op 0 allocs/op
BenchmarkDepth1-10 182873350 6.641 ns/op 0 B/op 0 allocs/op
BenchmarkDepth10-10 37763506 31.83 ns/op 0 B/op 0 allocs/op
BenchmarkDepth10-10 37205312 32.51 ns/op 0 B/op 0 allocs/op
BenchmarkDepth10-10 36783393 31.87 ns/op 0 B/op 0 allocs/op
BenchmarkDepth100-10 3930460 308.5 ns/op 0 B/op 0 allocs/op
BenchmarkDepth100-10 3915338 308.9 ns/op 0 B/op 0 allocs/op
BenchmarkDepth100-10 3898213 310.7 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 14.101sThe cost grows linearly with depth, in the worst case of looking up the first-added key. On an Apple M1 Max the worst-case lookup took about 6.6 ns at depth 1, 32 ns at depth 10 and 309 ns at depth 100, with no allocations. A real request has a handful of values, so the lookup is negligible (see context explained for how the chain is built and context cancellation in web services for the request side). The lesson is not to avoid values for speed but to avoid filling a context with dozens of entries: store one struct holding the request's data under one key if you find yourself adding more than a few.
Mistakes to avoid
Using it to dodge a signature. If a function needs a *sql.DB, the parameter list should say so. Storing mutable shared state, such as a map that handlers add to, which turns the context into a global. Using string or other built-in keys. Reading a value and assuming it exists, with a bare type assertion that panics when middleware was skipped, as in a unit test. Putting large objects in (a whole request body, a database result), keeping them alive as long as any derived context. Using it for configuration. Configuration is constructed once, passed to constructors, and not part of a request.
The reference is the context.WithValue documentation, whose text on keys and on the intended use is worth reading in full, and the Go blog's Context article, which motivated the type.


