- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Go service for the first time and grep for ctx.Value. You find a user ID stuffed in there. A request ID. A feature flag. A database transaction. A logger. Some configuration. A correlation token. The signature of every internal function is the same: func doThing(ctx context.Context) error. Everything travels in the bag.
It works. Until you trace why a query is reading from a stale replica, and you discover a middleware overrode the transaction key three layers up, and the only way to know that is to read every file between the handler and the repository.
context.Context is not a parameter bag. It is a tree. The standard library's context package is built around two operations: deriving a child from a parent, and watching the child for a cancel signal that flows down from any ancestor. Once you see it as a tree, you stop reaching for ctx.Value to dodge a signature change.
The tree is the point
Every context starts at a root — usually context.Background() or context.TODO(). From there, you derive children with WithCancel, WithTimeout, WithDeadline, or WithValue. Each derived context holds a pointer to its parent.
When the parent is cancelled, every descendant is cancelled. That signal flows down the tree, never up. A child cannot cancel its parent. A sibling cannot cancel its sibling. Only ancestors can stop their descendants.
func handle(rootCtx context.Context) {
// One parent.
parent, cancel := context.WithCancel(rootCtx)
defer cancel()
// Three siblings, each derived from parent.
go fetchUser(parent)
go fetchOrders(parent)
go fetchPayments(parent)
// When `cancel()` runs, all three children
// see ctx.Done() close at the same time.
}
If fetchOrders panics or returns an error and you call cancel(), the other two goroutines observe parent.Done() and shut down. That is the tree at work. There is no shared mutable state. There is no broadcast channel you wired by hand. The shape of the tree is the coordination.
The most common mistake here is forgetting defer cancel(). The WithCancel function returns a cancel function precisely so the runtime can release the timer goroutine and the parent's reference to this child. Skip it and you have a memory leak that go vet will flag, but only if you ask. Always defer the cancel. Always.
Deadlines compose: earliest wins
The next thing the tree does for you, free, is compose deadlines. Every child inherits its parent's deadline. If you set a tighter one on the child, the tighter one wins. If you set a looser one, the parent's wins. The effective deadline of any context is the earliest deadline anywhere on the path from the root.
// HTTP handler has a 5-second SLA.
ctx, cancel := context.WithTimeout(
r.Context(),
5*time.Second,
)
defer cancel()
// Repository call wants to give itself 10 seconds.
// It does not get 10. It gets at most 5,
// because the parent deadline is tighter.
queryCtx, queryCancel := context.WithTimeout(
ctx,
10*time.Second,
)
defer queryCancel()
rows, err := db.QueryContext(
queryCtx,
`SELECT id FROM orders WHERE user_id = $1`,
userID,
)
The handler has an SLA. The repository does not get to override it. If the repository wanted to bound itself to one second to keep the database connection healthy, it could pass 1*time.Second and that would win. The earliest deadline on the path is the one that matters.
The implication for design: pass ctx down through every blocking call that can take meaningful time — database queries, HTTP clients, message publishes, file I/O. Do not invent a private timeout in a function that already has a context. The caller gave you a budget. Spend it.
ctx.Value is not for parameters
Now the part that breaks codebases.
context.WithValue exists. It stores a key-value pair on the context. It looks like a tempting way to pass things down without changing every signature. The Go standard library docs are explicit about what it is for:
Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
That guidance is on the package page and it is the rule that, when you violate it, makes a service unmaintainable.
What counts as "request-scoped data that transits processes"? Tracing IDs. The correlation token your gateway attached at the edge. A request-scoped logger that has those IDs already baked in. The authenticated principal as set by middleware. Things that are non-functional — they describe the request, they do not change what the function does.
What does not count: the user ID your service operates on. The feature flag that decides which branch to take. The database transaction. The retry budget. Configuration. Anything the function actually reads to make a decision.
Here is the anti-pattern:
type ctxKey string
const userIDKey ctxKey = "user_id"
const txKey ctxKey = "tx"
func ChargeUser(ctx context.Context) error {
// Where does this user come from?
// Who set it?
// What if it is missing?
userID, ok := ctx.Value(userIDKey).(string)
if !ok {
return errors.New("user_id missing")
}
// And this. Set by which middleware?
tx, _ := ctx.Value(txKey).(*sql.Tx)
if tx == nil {
return errors.New("no tx in context")
}
return chargeWith(tx, userID)
}
ChargeUser says nothing about what it depends on. The signature is a lie. To know whether you can call this function, you read its body. To know whether the caller set everything up correctly, you read every middleware between the entry point and this call.
Worse, the type system is not helping. ctx.Value(userIDKey).(string) is a runtime cast. If a refactor changes the key type or the value type, the compiler does not notice. You catch it the next time the function runs in production. Maybe.
The fix is the most boring thing imaginable: pass the parameters as parameters.
func ChargeUser(
ctx context.Context,
tx *sql.Tx,
userID string,
) error {
return chargeWith(ctx, tx, userID)
}
Now the signature documents the dependencies. The compiler enforces them. A test can call this function without setting up middleware. A reader of the function knows, from the first line, what it needs. The context still travels along, because it carries the cancellation tree and the deadline. That is its job. It is not the job of ctx to carry your business inputs.
The narrow exception
There is a real, narrow case for ctx.Value. The middleware boundary.
Your HTTP middleware extracts a trace ID and a request-scoped logger. Every function deep in the call graph might want to log with that logger so the trace ID appears on every line. Threading the logger through every signature would touch hundreds of functions for a non-functional concern.
type loggerKey struct{}
func WithLogger(
ctx context.Context,
log *slog.Logger,
) context.Context {
return context.WithValue(
ctx, loggerKey{}, log,
)
}
func LoggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(
loggerKey{},
).(*slog.Logger); ok {
return l
}
return slog.Default()
}
Two things make this acceptable. First, the key is an unexported struct type, not a string — collisions are impossible. Second, LoggerFrom always returns a working logger, never nil. Code that calls it does not need to check. The context value is an enrichment, not a contract.
If your function would refuse to run when the value is missing, it is a parameter, not context data. Pass it as an argument.
What the tree gives you
Cancellation flows down. Deadlines compose to the earliest. Values are for cross-cutting metadata, not parameters. Once they sit in your head as one tree, the design questions answer themselves.
You stop reaching for ctx.Value to avoid changing a signature, because changing the signature is the right move. You start setting timeouts at the layer that owns the SLA, because you trust the tree to enforce them downstream. And defer cancel() stops slipping your mind, because a context without its cancel function is half a context. The next person who reads your service can answer "where does this user ID come from" by reading one function signature, not the entire middleware chain.
If this saved you a debugging session
Context is one of those parts of Go that rewards a clean mental model. The whole language does, really — once you see goroutines as cheap functions, channels as typed pipes, and interfaces as consumer-owned contracts, the rest of Go stops being surprising. The Complete Guide to Go Programming walks through that mental model end to end, with the kind of runnable examples you can drop into a project the same afternoon.
If your day job is shipping Go alongside an AI coding assistant, Hermes IDE is the editor I build for that workflow. It is built for the loop where the AI is reading and editing your Go code with you, not at you.

Top comments (0)