context Values: When and When Not
context.WithValue carries request-scoped metadata down call stacks without threading ten string parameters through every function.
Used well, values hold trace IDs and auth principals; used poorly, they become a global map for optional business data.
Summary
Store cross-cutting infrastructure metadata in context with unexported typed keys.
Pass business inputs as explicit function parameters.
Never put secrets in context values without understanding they can leak through logging and introspection.
Recipe
Quick-reference recipe card - copy-paste ready.
type ctxKey int
const (
requestIDKey ctxKey = iota
userKey
)
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) string {
v, _ := ctx.Value(requestIDKey).(string)
return v
}When to reach for this:
- Middleware attaches request IDs, auth subjects, or locale after parsing headers.
- Loggers and metrics exporters read values at leaf calls without changing every signature.
- Do not store optional filters, pagination, or feature toggles that belong in structs.
Working Example
package main
import (
"context"
"fmt"
"log/slog"
)
type ctxKey string
const (
requestIDKey ctxKey = "requestID"
userKey ctxKey = "user"
)
type User struct {
ID string
Role string
}
func withMeta(ctx context.Context, reqID string, u User) context.Context {
ctx = context.WithValue(ctx, requestIDKey, reqID)
return context.WithValue(ctx, userKey, u)
}
func logAction(ctx context.Context, action string) {
attrs := []any{slog.String("action", action)}
if id, ok := ctx.Value(requestIDKey).(string); ok && id != "" {
attrs = append(attrs, slog.String("request_id", id))
}
if u, ok := ctx.Value(userKey).(User); ok && u.ID != "" {
attrs = append(attrs, slog.String("user_id", u.ID))
}
slog.Info("audit", attrs...)
}
func serviceCall(ctx context.Context) {
logAction(ctx, "billing.sync")
}
func main() {
ctx := withMeta(context.Background(), "req-7f3a", User{ID: "u-42", Role: "admin"})
serviceCall(ctx)
fmt.Println("done")
}What this demonstrates:
- String-backed unexported key types avoid collisions between packages.
- Leaf functions enrich logs from context without extra parameters.
- Values are optional - type assertions tolerate missing keys.
- Business action names stay explicit; only metadata comes from context.
Deep Dive
How It Works
- Context values form a linked chain;
Valuewalks parent pointers until a key matches. - Later
WithValuecalls shadow parent keys with the same type and value. - Values are immutable - updates require a new child context via
WithValue. - There is no enumeration API - consumers must know keys in advance.
Acceptable vs Unacceptable Values
| Acceptable | Unacceptable |
|---|---|
| Request / trace ID | Database handles |
| Authenticated principal snapshot | Large request payloads |
| Locale or tenant slug | Optional query filters |
| Logger or tracer handle (carefully) | Configuration structs |
| Deadline policy flags (rare) | Secrets in plain text |
Middleware Attachment (net/http)
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := UserFromJWT(r.Header.Get("Authorization"))
ctx := context.WithValue(r.Context(), userKey, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Prefer small value types copied by value or immutable pointers.
Go Notes
// Package auth owns its keys - other packages cannot collide
package auth
type key struct{}
var userKey = key{}
func User(ctx context.Context) (User, bool) {
u, ok := ctx.Value(userKey).(User)
return u, ok
}Gotchas
- String keys in libraries - Third-party packages can collide on
"user". Fix: unexported typed keys per package. - Storing database connections - Lifetime does not match request scope cleanly. Fix: pass
*sql.DBvia constructors, not context. - Mutating value structs after attach - Race if goroutines share pointers. Fix: store immutable snapshots or copy on write.
- Using context for optional params - Hides API surface and breaks static analysis. Fix: options struct or explicit parameters.
- Logging entire context - No stringify API; values may include PII. Fix: allowlist keys for log exporters.
- Nil interface values -
WithValue(ctx, key, nil)still stores an entry. Fix: omit key when absent or use pointer types.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Explicit struct parameter | Clear API with few fields | Deep middleware-only metadata |
context.Value | Cross-cutting IDs across packages | Business logic inputs |
| Thread-local style (not in Go) | N/A in Go | - |
| OpenTelemetry baggage | Standard tracing propagation | Simple monolith without tracing |
| HTTP headers re-read at leaf | Stateless microservices | Hot paths calling same metadata repeatedly |
FAQs
How many values are too many?
If you need more than a handful, reconsider an explicit request struct.
Context values should stay tiny and stable.
Can I store a logger in context?
Some teams do; others inject loggers via constructors.
If stored, use a narrow interface and avoid mutable state.
Should tests set the same keys?
Yes - use helper constructors in _test.go to attach test metadata.
Keeps production key types unexported.
Are context values secure?
They are not encrypted or access-controlled.
Do not treat context as a secrets vault.
How does grpc metadata relate?
gRPC metadata is wire-level; context values are in-process.
Interceptors often copy metadata into context for handlers.
Can values cross service boundaries?
Not automatically - serialize chosen fields into headers or metadata explicitly.
Recreate values in receiving middleware.
What about cancellation vs values?
Cancellation and deadlines are first-class context features.
Values are ancillary - do not use them to signal stop.
Should I use generics for typed accessors?
Typed helper functions (User(ctx)) are idiomatic.
Generics rarely simplify beyond plain functions.
Does WithValue affect cancel?
No - child contexts inherit parent cancellation.
Values add a layer without changing Done behavior.
How do linters help?
golangci-lint and review checklists flag string keys and context-as-config patterns.
Enforce package-owned key types in style guides.
Related
- context Basics - WithValue introductory example
- Cancellation Propagation in HTTP Handlers - middleware wiring
- context in database/sql & gRPC - metadata in RPC
- Context Misuse Anti-Patterns - value abuse patterns
- context Package Best Practices - key ownership rules
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).