Alright, let’s talk about context.WithValue. This is the part of the context package that everyone loves to misuse. It feels like a magical key-value store you can attach to a request. And it is! But it’s a very specific kind of magic, like a spell that only works if you cast it with the exact, correct, and previously agreed-upon incantation. Screw that up, and you’ll summon a eldritch horror of nil pointers and race conditions.

The core idea is simple: sometimes, in the deeply nested caverns of your call stack, you need a piece of information that was available way up at the surface where the request started. Think trace IDs, user authentication tokens, request-specific loggers. Passing them as explicit function parameters through ten layers of abstraction is the “correct” way, but it’s also a massive pain and makes your function signatures look like alphabet soup. context.WithValue offers an alternative. But heed this warning: it is not a substitute for proper dependency injection. It’s for request-scoped, immutable, ancillary data.

How It Actually Works

You don’t just shove a value into a context willy-nilly. You start with a parent context (usually the one from http.Request or a background context) and create a new contextderived from it, carrying your new key-value pair.

// The key is CRITICAL. It cannot be just any string. More on this in a second.
type contextKey string

const (
    requestIDKey contextKey = "requestID"
    userKey      contextKey = "user"
)

func someHandler(w http.ResponseWriter, r *http.Request) {
    // ... at the start of a request ...
    ctx := r.Context()
    
    // Create a new context with a request ID value
    reqID := generateRequestID()
    ctx = context.WithValue(ctx, requestIDKey, reqID)
    
    // And now pass 'ctx' down the chain instead of the original
    someDeepFunction(ctx)
}

func someDeepFunction(ctx context.Context) {
    // Retrieve the value
    val := ctx.Value(requestIDKey)
    
    // Remember: Value returns an interface{}. You MUST type assert.
    reqID, ok := val.(string)
    if !ok {
        // Handle the case where the value isn't there or isn't a string.
        // This is NOT a programming error; it's a flow you must anticipate.
        log.Printf("request ID not found or not a string")
        return
    }
    log.Printf("Processing request ID: %s", reqID)
}

Notice we created a new type, contextKey, for our keys. This isn’t me being pedantic; it’s the single most important best practice to avoid collisions. If you use a built-in type like string, two different packages could use the same key string (“id”) and accidentally overwrite or read each other’s values. Using a private, custom type ensures your key is unique within the universe of your package.

The Golden Rule: Use For Request-Scoped Data, Not For Passing Optional Parameters

This is where everyone goes wrong. They see context and think, “Great! I’ll use this to pass my database connection pool down to my functions!” No. Just no. Stop it.

The context should carry data that is directly relevant to the operation of the request, not the infrastructure required to execute it. Your database pool is a dependency. It should be injected into your structs or functions explicitly on startup. A user’s authorization token is request-scoped data; it belongs in the context.

Do put in context:

  • Request IDs
  • Trace IDs for distributed tracing
  • An authenticated user object (or user ID)
  • Request deadlines (handled by WithTimeout/WithDeadline)

Do NOT put in context:

  • Database handles
  • HTTP clients
  • Loggers (though a logger with a pre-populated request ID is a grey area)
  • Configuration structs
  • Anything that isn’t immutable

The Ugly Truth About Retrieval

Retrieving values is, frankly, a bit clunky. ctx.Value(key) returns an interface{}, so you’re immediately thrust into the world of type assertions. And it can return nil—meaning the key simply doesn’t exist in this context’s chain. Your code must be defensive and handle both the “not found” case and the “found but wrong type” case gracefully. This is by design. It forces you to acknowledge that the value might not be there, which is a stark contrast to the safety of explicit function parameters.

Performance? Don’t Worry About It.

A common concern is the performance cost of creating a chain of contexts. The Go team is brilliant, and the context implementation is a linked list designed to be extremely lightweight. Each new context with a value just points to its parent. The Value method walks up the chain until it finds a match or hits the root. It’s incredibly fast for the intended use case. You are almost certainly thinking about a bigger performance bottleneck elsewhere in your code. I promise.

So, use context.WithValue, but treat it like a sharp knife: incredibly useful for the right task, but you’ll bleed if you use it carelessly. Define your own key types, only store what’s truly request-scoped, and always, always check your type assertions.