Context Values: The Good, The Bad, and The Ugly
Context values are probably the most controversial part of Go’s context package. I’ve seen teams ban them entirely, and I’ve seen other teams abuse them so badly that debugging becomes a nightmare. The truth is somewhere in the middle—context values are incredibly useful when used correctly, but they’re also easy to misuse.
Here’s my rule of thumb: context values should carry information about the request, not information for the request. Think user IDs, trace IDs, request metadata—stuff that helps you understand what’s happening, not stuff your business logic depends on.
Type-Safe Context Keys (No More String Collisions)
The biggest mistake I see with context values is using string keys. That leads to collisions, typos, and runtime panics. Here’s how to do it right:
// Define unexported key types to prevent collisions
type contextKey string
const (
userIDKey contextKey = "user_id"
requestIDKey contextKey = "request_id"
traceIDKey contextKey = "trace_id"
)
// Type-safe setters
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}
// Type-safe getters with proper error handling
func GetUserID(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey).(string)
return userID, ok
}
func GetRequestID(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey).(string)
return requestID, ok
}
// Convenience function for when you don't care about the bool
func MustGetUserID(ctx context.Context) string {
if userID, ok := GetUserID(ctx); ok {
return userID
}
return "unknown"
}
The unexported contextKey
type prevents other packages from accidentally using the same keys. The type assertions in getters ensure you handle missing values gracefully.
Request Metadata Pattern
Instead of scattering individual values throughout your context, I prefer bundling related metadata together:
type RequestInfo struct {
ID string
UserID string
TraceID string
StartTime time.Time
UserAgent string
IPAddress string
}
type requestInfoKey struct{}
func WithRequestInfo(ctx context.Context, info RequestInfo) context.Context {
return context.WithValue(ctx, requestInfoKey{}, info)
}
func GetRequestInfo(ctx context.Context) (RequestInfo, bool) {
info, ok := ctx.Value(requestInfoKey{}).(RequestInfo)
return info, ok
}
// HTTP middleware to populate request info
func RequestInfoMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := RequestInfo{
ID: generateRequestID(),
TraceID: r.Header.Get("X-Trace-ID"),
StartTime: time.Now(),
UserAgent: r.UserAgent(),
IPAddress: getClientIP(r),
}
// Extract user ID from JWT or session
if userID := extractUserID(r); userID != "" {
info.UserID = userID
}
ctx := WithRequestInfo(r.Context(), info)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
This approach keeps related data together and makes it easy to add new fields without changing function signatures throughout your codebase.
Context Composition (Merging Contexts Safely)
Sometimes you need to combine contexts from different sources while preserving important values:
type ContextMerger struct {
preserveKeys []interface{}
}
func NewContextMerger(keys ...interface{}) *ContextMerger {
return &ContextMerger{preserveKeys: keys}
}
func (cm *ContextMerger) Merge(base, source context.Context) context.Context {
result := base
// Copy specific values from source to base
for _, key := range cm.preserveKeys {
if value := source.Value(key); value != nil {
result = context.WithValue(result, key, value)
}
}
return result
}
// Example: Creating a background task with request context
func scheduleBackgroundTask(requestCtx context.Context, task Task) {
// Create a background context that won't be cancelled with the request
bgCtx := context.Background()
// But preserve important request metadata
merger := NewContextMerger(userIDKey, requestIDKey, traceIDKey)
taskCtx := merger.Merge(bgCtx, requestCtx)
go processTask(taskCtx, task)
}
This lets you create new contexts with different cancellation behavior while keeping the metadata you need for logging and tracing.
Structured Logging with Context
One of the best uses for context values is enriching your logs automatically:
type ContextLogger struct {
logger *slog.Logger
}
func NewContextLogger(logger *slog.Logger) *ContextLogger {
return &ContextLogger{logger: logger}
}
func (cl *ContextLogger) Info(ctx context.Context, msg string, args ...any) {
attrs := cl.extractContextAttrs(ctx)
cl.logger.Info(msg, append(attrs, args...)...)
}
func (cl *ContextLogger) Error(ctx context.Context, msg string, err error, args ...any) {
attrs := cl.extractContextAttrs(ctx)
attrs = append(attrs, slog.String("error", err.Error()))
cl.logger.Error(msg, append(attrs, args...)...)
}
func (cl *ContextLogger) extractContextAttrs(ctx context.Context) []any {
var attrs []any
if info, ok := GetRequestInfo(ctx); ok {
attrs = append(attrs,
slog.String("request_id", info.ID),
slog.String("user_id", info.UserID),
slog.String("trace_id", info.TraceID),
)
}
return attrs
}
// Usage in your handlers
func handleUserUpdate(ctx context.Context, logger *ContextLogger, req UpdateRequest) error {
logger.Info(ctx, "Starting user update", slog.String("operation", "update_user"))
if err := validateUpdate(req); err != nil {
logger.Error(ctx, "Validation failed", err)
return err
}
logger.Info(ctx, "User update completed successfully")
return nil
}
Now every log entry automatically includes request context without you having to remember to add it manually.
Context Value Validation
In production systems, you want to validate context values to prevent bad data from propagating:
type ContextValidator struct {
rules map[interface{}]ValidationRule
}
type ValidationRule func(interface{}) error
func NewContextValidator() *ContextValidator {
return &ContextValidator{
rules: make(map[interface{}]ValidationRule),
}
}
func (cv *ContextValidator) AddRule(key interface{}, rule ValidationRule) {
cv.rules[key] = rule
}
func (cv *ContextValidator) ValidateAndSet(ctx context.Context, key interface{}, value interface{}) (context.Context, error) {
if rule, exists := cv.rules[key]; exists {
if err := rule(value); err != nil {
return ctx, fmt.Errorf("validation failed for key %v: %w", key, err)
}
}
return context.WithValue(ctx, key, value), nil
}
// Set up validation rules
func setupValidator() *ContextValidator {
validator := NewContextValidator()
// User ID must be non-empty and reasonable length
validator.AddRule(userIDKey, func(v interface{}) error {
userID, ok := v.(string)
if !ok {
return fmt.Errorf("user ID must be string")
}
if len(userID) == 0 || len(userID) > 64 {
return fmt.Errorf("user ID length must be 1-64 characters")
}
return nil
})
return validator
}
This prevents malformed data from causing problems downstream.
Performance Considerations
Context value lookups can get expensive in deep call stacks. Here’s a caching pattern for frequently accessed values:
type ContextCache struct {
cache sync.Map // key -> value
}
func NewContextCache() *ContextCache {
return &ContextCache{}
}
func (cc *ContextCache) Get(ctx context.Context, key interface{}) (interface{}, bool) {
// Check cache first
if value, exists := cc.cache.Load(key); exists {
return value, true
}
// Cache miss - check context
value := ctx.Value(key)
if value != nil {
cc.cache.Store(key, value)
return value, true
}
return nil, false
}
func (cc *ContextCache) Clear() {
cc.cache.Range(func(key, value interface{}) bool {
cc.cache.Delete(key)
return true
})
}
// Use it for expensive lookups
func expensiveOperation(ctx context.Context) error {
cache := NewContextCache()
defer cache.Clear()
// This lookup is now cached
if userID, ok := cache.Get(ctx, userIDKey); ok {
// Use userID
_ = userID
}
return nil
}
This reduces the overhead of repeated context value lookups in performance-critical paths.
What NOT to Put in Context
Let me be clear about what shouldn’t go in context values:
// DON'T do this - business logic data doesn't belong in context
func badExample(ctx context.Context) error {
// This is wrong - database connections should be injected properly
db := ctx.Value("database").(*sql.DB)
// This is wrong - configuration should be explicit
config := ctx.Value("config").(*Config)
// This is wrong - business data should be parameters
userData := ctx.Value("user_data").(*User)
return nil
}
// DO this instead - explicit dependencies and parameters
func goodExample(ctx context.Context, db *sql.DB, config *Config, user *User) error {
// Context only carries request metadata
if requestID, ok := GetRequestID(ctx); ok {
log.Printf("Processing request %s", requestID)
}
return nil
}
Context values are for request-scoped metadata, not for dependency injection or passing business data around.
The key insight about context values is that they should enhance observability and request coordination without becoming a crutch for poor API design. When used correctly, they provide powerful capabilities for tracing, logging, and metadata propagation. When abused, they make code harder to understand and test.
Next, we’ll explore advanced context patterns that combine everything we’ve learned so far to solve complex coordination problems in distributed systems.