The side channel for request data
You are building a service. An HTTP handler receives a request with a trace ID in the header. You spawn a goroutine to process the heavy lifting. That goroutine needs the trace ID to log errors. You can't pass the ID as an argument because the handler signature is fixed by the router, or the goroutine calls a chain of functions that don't expect the ID. You need a way to thread the needle.
context.WithValue is the thread. It attaches a key-value pair to the context, which flows down to every child call and goroutine. The value lives for the lifetime of the request. It disappears when the context is cancelled.
Context as a carrier, not a database
A context carries deadlines, cancellation signals, and request-scoped values. It is immutable. You never modify a context in place. Every time you add a value or set a deadline, you get a new context that wraps the parent.
Think of a context like a request envelope. The envelope travels from the entry point to the deepest function. context.WithValue sticks a note inside the envelope. Functions can read the note. They can also add their own notes, creating a new envelope that contains the old one.
The context interface defines Value(key any) any. When you call Value, the context walks up the chain of parents until it finds the key. If the key is not found, it returns nil. This walk is fast because the chain is usually shallow. The cost is negligible compared to network I/O or database queries.
Context is plumbing. Run it through every long-lived call site.
Minimal example: keys and values
Here's the mechanics: define a key, wrap the value, retrieve with a type assertion.
package main
import (
"context"
"fmt"
)
// main demonstrates storing and retrieving a value in a context.
func main() {
// Define a custom key type to avoid collisions with other packages.
// Using a struct type is the safest approach; the compiler ensures uniqueness.
type traceKey struct{}
// Derive a new context carrying the trace ID.
// context.Background() is the root. WithValue returns a new context.
ctx := context.WithValue(context.Background(), traceKey{}, "req-12345")
// Pass ctx to a function that needs the trace ID.
processRequest(ctx)
}
// processRequest retrieves the trace ID from the context.
func processRequest(ctx context.Context) {
// Retrieve the value. The type assertion checks the type at runtime.
// Use the comma-ok idiom to handle missing keys gracefully.
traceID, ok := ctx.Value(traceKey{}).(string)
if !ok {
fmt.Println("trace ID missing or wrong type")
return
}
fmt.Println("Processing with trace:", traceID)
}
Functions that accept a context should take it as the first parameter, conventionally named ctx. This makes the dependency obvious and allows tools to analyze context flow. The receiver name in methods is usually one or two letters matching the type, but ctx is the universal convention for context parameters.
Walkthrough: allocation and lookup
When you call context.WithValue, Go allocates a small struct on the heap. This struct holds a pointer to the parent context, the key, and the value. The value is stored as an any interface, which means the actual data is also on the heap if it isn't already.
Retrieving the value calls the Value method. The implementation checks if the current context holds the key. If not, it delegates to the parent. This continues until the key is found or the root is reached.
The value stays in memory until the context is cancelled or the last reference to the context is dropped. If you store a large object, that object is pinned in memory for the entire request lifetime. The garbage collector cannot reclaim it while the context exists.
Context is a carrier, not a database. Keep values small and transient.
Realistic example: middleware and goroutines
In a real service, you attach the value in middleware or the handler, then pass the context to background tasks. This ensures every log line and database query includes the request metadata.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// keyType defines a unique type for context keys.
// Using a distinct type prevents collisions with string keys from other packages.
type keyType string
// traceKey is the key used to store the trace ID.
const traceKey keyType = "trace-id"
// handleRequest simulates an HTTP handler using context.WithValue.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Extract trace ID from header and attach it to the request context.
// r.Context() is the context provided by the server.
traceID := r.Header.Get("X-Trace-ID")
ctx := context.WithValue(r.Context(), traceKey, traceID)
// Spawn a background goroutine that inherits the context.
go func() {
// The goroutine can access the trace ID via ctx.
// If the request is cancelled, ctx.Done() signals the goroutine to stop.
doWork(ctx)
}()
w.WriteHeader(http.StatusOK)
}
// doWork performs a task and logs using the trace ID.
func doWork(ctx context.Context) {
// Retrieve the trace ID. The type assertion is safe here because
// we control the key type and the value type.
traceID := ctx.Value(traceKey).(string)
// Simulate work.
time.Sleep(100 * time.Millisecond)
fmt.Printf("[%s] Work done\n", traceID)
}
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Context provides that path via Done(). The goroutine should select on ctx.Done() to exit promptly when the request ends.
Pitfalls and compiler errors
Using context.WithValue correctly requires discipline. The wrong choices lead to subtle bugs, memory leaks, or panics.
String keys invite collisions
If you use a string like "trace-id" as the key, another package might use the same string. The lookup returns the wrong value. The compiler won't catch this because strings are untyped keys.
// BAD: String keys collide across packages.
ctx := context.WithValue(parent, "user-id", "alice")
// Another package might do this:
// ctx := context.WithValue(parent, "user-id", 12345)
// Now ctx.Value("user-id") returns 12345, not "alice".
Always use a custom type. A struct{} or a named type like type key string ensures the key is unique to your package. The compiler treats key("foo") and string("foo") as different types.
Type assertion panics
Retrieving a value requires a type assertion. If the key is missing, Value returns nil. Asserting nil to a concrete type panics.
The compiler rejects this with interface conversion: interface {} is nil, not string if you use the short form without checking. Always use the comma-ok idiom:
val, ok := ctx.Value(key).(string)
if !ok {
// Handle missing value.
}
Memory leaks with large values
Contexts hold references to values. If you store a large buffer or a struct with many fields, that memory cannot be garbage collected until the context is cancelled.
For example, storing a request body in the context keeps the body in memory even after you've parsed it. The body should be read, parsed, and discarded. The context should only hold the parsed result, which is usually small.
Sensitive data exposure
Contexts can be logged or inspected by middleware. Some logging libraries dump the entire context for debugging. Storing passwords, tokens, or PII in the context risks leaking them to logs.
Encrypt sensitive data before storing it, or better yet, pass it through a secure channel that isn't exposed to logging. Context is for metadata, not secrets.
The compiler and unused imports
Forget to import a package and you get undefined: pkg from the compiler. Forget to use one and you get imported and not used. Go is strict about imports. If you import context but don't use it, the build fails. This rule keeps code clean and dependencies explicit.
Custom keys prevent collisions. String keys invite chaos.
Decision: when to use context.WithValue
Use context.WithValue when you need to pass request-scoped data across goroutine boundaries and no other API exists. Use explicit function arguments when the data flows through a single call chain and the signature can be updated. Use a dedicated struct or closure when the data is large or complex, and you want to avoid context overhead. Use a global variable only for truly immutable configuration that never changes per request. Use context.WithCancel when you need to stop work, not to pass data.
If you can pass it as an argument, do that instead. Arguments are visible in the signature. Context values are hidden until you dig.