The vanishing metadata problem
You are building an HTTP service. A request arrives with a correlation ID. You need that ID to flow through your handler, into your business logic, and finally into your database query so you can trace slow operations later. You reach for context.WithValue, attach the ID, and pass the context down. Three functions later, the ID is gone. Or worse, your server starts consuming gigabytes of RAM because you accidentally attached a full user profile to a context that never gets garbage collected. The problem is rarely the context itself. It is how you handle the return value and what you choose to store inside it.
Context propagation is not optional. It is the backbone of request-scoped data in Go. Treat the return value of context.WithValue as a new object that must replace the old one in every downstream call.
How context chains actually work
A context in Go is an immutable chain of metadata. Think of it like a relay race baton. When you call context.WithValue, you do not modify the existing baton. You hand the runner a brand new baton with a tag tied to it. The old baton still exists, unchanged, in the previous runner's hand. If you keep using the old baton, the tag never travels down the track. Every call to WithValue creates a new context node that points back to the parent. The chain grows one link at a time.
This design guarantees that concurrent code can read the context safely without locks. Multiple goroutines can inspect the same context simultaneously because the underlying data never changes after creation. The tradeoff is explicit propagation. You cannot mutate a context in place. You must create a new one and thread it through your functions. The language forces you to be deliberate about where metadata flows.
Contexts are cheap to create but expensive to leak. Always pass the new context forward.
The minimal pattern
// AttachValue creates a new context carrying a single piece of data.
func AttachValue(parent context.Context, key string, val any) context.Context {
// WithValue returns a new context. The parent remains untouched.
ctx := context.WithValue(parent, key, val)
// Always use the returned context for downstream calls.
return ctx
}
The function above demonstrates the core rule. context.WithValue returns a new context. You must assign that return value to a variable and pass that variable to every function that needs the data. If you call context.WithValue(parent, key, val) and ignore the result, the new context is created, immediately discarded, and the parent context continues down the call stack completely unaware of the new value.
Static analysis tools catch this mistake instantly. The unusedresult checker in staticcheck flags the line with SA1029: context.WithValue result is not used. The compiler itself will not stop you because context.WithValue is a standard library function, not a language builtin. You rely on linters to enforce the discipline. Run gofmt on save and let your linter gate the build. Trust the tooling to catch dropped return values.
Walking the chain at runtime
At runtime, Go represents a context as a tree of nodes. Each node holds a key-value pair and a pointer to its parent. When you call ctx.Value(key), Go walks up the parent pointers until it finds a match or reaches the root. This lookup is fast for shallow chains, but it costs time and memory if you build deeply nested trees. The immutability is the key feature. Because contexts never change after creation, multiple goroutines can read the same context simultaneously without data races.
The convention for functions that accept a context is strict. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If you ignore the first parameter, you break the cancellation chain and risk goroutine leaks. The compiler will not warn you about unused parameters, but the runtime will keep your goroutines alive until the parent context expires. Thread ctx through every long-lived call site.
Real-world propagation
Real applications rarely pass raw strings as context keys. Collisions happen when two different packages accidentally use the same string key. The standard practice is to define a private type for your keys and use a zero value of that type as the actual key. This guarantees uniqueness without relying on string matching.
// traceKey is an unexported type to prevent key collisions across packages.
type traceKey struct{}
// WithTraceID attaches a trace identifier to the context chain.
func WithTraceID(ctx context.Context, id string) context.Context {
// Use the zero value of the private type as the key.
// This prevents other packages from accidentally overwriting the value.
return context.WithValue(ctx, traceKey{}, id)
}
// GetTraceID retrieves the trace identifier from the context.
func GetTraceID(ctx context.Context) (string, bool) {
// Type assertion handles the case where the key is missing.
// The bool return value lets callers check for existence safely.
id, ok := ctx.Value(traceKey{}).(string)
return id, ok
}
This pattern keeps your context clean and type-safe. The traceKey struct is never instantiated directly in other packages, so no external code can accidentally overwrite your trace ID. You still follow the same propagation rule. Call WithTraceID, capture the new context, and pass it forward. The receiver name in methods is usually one or two letters matching the type, but context helpers are typically plain functions. Keep the API flat and predictable.
Pitfalls and silent failures
Ignoring the return value is the most common mistake. Linters will flag it, but if you disable them or run them inconsistently, the bug slips into production. The runtime will not panic. It will simply return the wrong data or miss the data entirely. Debugging a missing trace ID in a distributed system is painful. Catch it at compile time.
Attaching large payloads is a silent memory leak. Contexts live as long as the longest-running operation in their chain. If you attach a 50MB request body to a context and pass it to a background goroutine that runs for hours, that 50MB stays pinned in memory. The garbage collector cannot reclaim it because the context node holds a direct reference. Keep context values small. Trace IDs, user IDs, and feature flags belong in a context. Request bodies, parsed JSON, and database rows belong in function parameters or dedicated structs.
Using basic types like string or int as keys invites collisions. If your logging package uses "userID" and your metrics package uses "userID", they will overwrite each other. The compiler will not complain. The runtime will simply return the wrong value when you call ctx.Value("userID"). Always use a private struct type or a custom type for keys.
The worst goroutine bug is the one that never logs. If you lose the context chain, you lose cancellation signals. Background tasks will run indefinitely. Always attach a timeout or deadline to the root context, and pass it down without breaking the chain.
Choosing the right carrier
Use context.WithValue when you need to pass request-scoped metadata through a call chain that spans multiple packages or layers. Use explicit function parameters when the data is required by only one or two functions in a tight scope. Use a dedicated request struct when you need to pass multiple related values together and want to enforce structure at compile time. Avoid context entirely when you are building background workers, long-running daemons, or library functions that should remain decoupled from HTTP or RPC lifecycles.
Context is plumbing. Run it through every long-lived call site.