DEV Community

Cover image for context.Context Is a Tree, Not a Bag. Treat It Like One
Gabriel Anhaia
Gabriel Anhaia

Posted on

context.Context Is a Tree, Not a Bag. Treat It Like One


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.
}
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)