Passing Values with context.WithValue in Go
How context.WithValue works, why keys should be unexported types, what belongs in a context and what should be a normal function parameter.
Besides cancellation, a context can carry small pieces of data that belong to one request: a request ID for logs, the authenticated user, a trace ID. context.WithValue attaches them, and ctx.Value(key) reads them anywhere further down the call chain.
Storing and Reading a Value
package main
import (
"context"
"fmt"
)
type ctxKey string
const requestIDKey ctxKey = "requestID"
func handle(ctx context.Context) {
logLine(ctx, "loading cart")
}
func logLine(ctx context.Context, msg string) {
id, _ := ctx.Value(requestIDKey).(string)
fmt.Printf("[%s] %s\n", id, msg)
}
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "req-7f3a")
handle(ctx)
}
This prints [req-7f3a] loading cart. logLine never received the ID as a parameter. It read it from the context that handle passed through.
ctx.Value returns any, so you need a type assertion to get a string back. The two-value form id, ok := ...(string) avoids a panic if the value is missing; here a missing ID just prints an empty string.
Use an Unexported Key Type
Keys are compared by type and value. If two packages both used the plain string "requestID" as a key, they would overwrite each other's values. Defining your own unexported type makes collisions impossible, because no other package can create a value of that type.
The most common style uses an empty struct type, which takes no memory:
package main
import (
"context"
"fmt"
)
type userKey struct{}
type User struct {
ID int
Name string
}
func WithUser(ctx context.Context, u User) context.Context {
return context.WithValue(ctx, userKey{}, u)
}
func UserFrom(ctx context.Context) (User, bool) {
u, ok := ctx.Value(userKey{}).(User)
return u, ok
}
func main() {
ctx := WithUser(context.Background(), User{ID: 1, Name: "asha"})
if u, ok := UserFrom(ctx); ok {
fmt.Println("logged in as", u.Name)
}
if _, ok := UserFrom(context.Background()); !ok {
fmt.Println("no user in an empty context")
}
}
Output:
logged in as asha
no user in an empty context
The WithUser and UserFrom helpers hide the key completely. Other code uses typed functions and never touches ctx.Value directly.
staticcheck warns about built-in key types:
Using a plain string or int as a key is flagged by staticcheck as SA1029: should not use built-in type string as key for value; define your own type to avoid collisions. Always define your own key type.
What Belongs in a Context
Context values are invisible in function signatures. That is convenient for data that crosses every layer but is not part of the actual job. It is harmful for data the function really needs.
Good fits:
- request IDs and trace IDs for logging and tracing
- the authenticated user or tenant, when read at the edges such as logging and access checks
- deadline-related metadata
Poor fits:
- database connections, loggers and config objects: pass these as struct fields or parameters
- function inputs like an order ID or a page number: pass them as parameters
- anything optional you use to avoid changing a signature
Hidden dependencies break silently:
A function that reads an order ID from the context compiles fine even when the caller forgot to set it, and fails only at run time. If a function cannot work without a value, make it a parameter so the compiler checks it.
Read at the Edge, Pass Down Explicitly
A clean pattern is to read context values once at the boundary, such as the HTTP handler, and pass plain values to the business logic:
package main
import (
"context"
"fmt"
)
type userKey struct{}
func handler(ctx context.Context) {
userID, ok := ctx.Value(userKey{}).(int)
if !ok {
fmt.Println("401 unauthorized")
return
}
fmt.Println(listOrders(ctx, userID))
}
func listOrders(ctx context.Context, userID int) string {
return fmt.Sprintf("orders for user %d", userID)
}
func main() {
handler(context.WithValue(context.Background(), userKey{}, 17))
handler(context.Background())
}
Output:
orders for user 17
401 unauthorized
listOrders still takes ctx for cancellation, but its real input, userID, is an ordinary parameter. Tests for it need no context setup beyond context.Background().
Healthy use of values:
If you could delete every ctx.Value call and only lose logging detail and tracing, not correctness, your context values are being used for the right things.
Summary
context.WithValue(ctx, key, val) attaches request-scoped data, and ctx.Value(key) reads it with a type assertion. Use an unexported key type, ideally behind WithX and XFrom helpers. Keep values to cross-cutting data like request IDs and the current user, and pass anything a function truly needs as a normal parameter.