context.Context appears in the signature of almost every non-trivial Go function. It’s the idiomatic way to carry deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines. After years of reading Go codebases, the misuse patterns are as common as the correct uses.
The Three Purposes of Context
context.Context
│
├── Cancellation / Done channel
│ "stop what you're doing, the caller has moved on"
│
├── Deadline / Timeout
│ "stop what you're doing by this absolute time"
│
└── Values
"carry request-scoped data without explicit parameters"
(use sparingly — this is the footgun)
These three purposes are distinct and have different correct usage patterns.
Cancellation: The Primary Use Case
The canonical pattern: a request comes in, spawns multiple goroutines to gather data, and should cancel all of them if the client disconnects.
| |
The crucial requirement: fetchFromServiceA and fetchFromServiceB must respect the context. If they ignore it — if they don’t pass it through to their HTTP clients, database calls, or other I/O — the cancellation signal has no effect and the goroutines run to completion anyway.
Every I/O call that accepts a context must receive the caller’s context. This is not optional. A function that creates a context.Background() internally and passes that to its I/O calls is not respecting cancellation.
Deadline Propagation Across Services
The most underused context feature in distributed systems: deadline propagation. When service A calls service B with a 500ms deadline, service B should know it only has 500ms and should not start work it can’t complete.
Without propagation:
Client → Service A (500ms budget) → Service B (no deadline set)
↓
B works for 2 seconds
A times out at 500ms, returns error to client
B finishes 1.5 seconds after A has given up
B's work is wasted, resources consumed for nothing
With propagation (gRPC does this automatically; HTTP requires explicit headers):
Client → Service A (500ms budget) → Service B (inherited ~490ms budget)
↓
B knows it has ~490ms
B can make intelligent decisions (skip optional enrichment, return partial data)
No wasted work after the deadline
In Go, ctx.Deadline() returns the absolute deadline, and most HTTP clients accept a context so the deadline is naturally enforced. For gRPC, the context deadline is propagated automatically as gRPC metadata.
The Values Footgun
context.WithValue lets you attach arbitrary key-value pairs to a context. It’s the most misused part of the package.
Correct uses — request-scoped metadata that would otherwise require threading through every function signature:
| Value type | Example | Why context is correct |
|---|---|---|
| Trace/span | opentelemetry.SpanFromContext(ctx) | Every function participates in tracing without accepting a Span param |
| Request ID | requestIDFromContext(ctx) | Logging correlation, no business logic depends on it |
| Auth principal | principalFromContext(ctx) | Security context, not domain logic input |
Incorrect uses — using context as a grab-bag to avoid fixing function signatures:
| |
This creates invisible dependencies. A function that silently reads from context has a hidden input that’s invisible in its signature. It’s impossible to see at the call site that the function needs this value, and if the value is absent, you get a runtime panic instead of a compile error.
Rule: if a function’s correctness depends on a context value, that value should be an explicit function parameter. Context values are for cross-cutting concerns (tracing, auth metadata, cancellation signals), not domain logic inputs.
Integrating Distributed Tracing
The context package is the mechanism by which OpenTelemetry (and before it, OpenCensus and Zipkin) carries trace context across function calls. The span lives in the context; each function that wants to add observability extracts the span, adds attributes or child spans, and passes the modified context forward.
| |
The trace context propagates automatically through every function that accepts and passes ctx. No explicit span management at each call site — just pass ctx and the tracing library handles the rest.
For HTTP outbound calls, inject the trace context into headers:
| |
For inbound HTTP requests, extract it:
| |
This connects spans across service boundaries into a single distributed trace — the mechanism that makes “show me the full request path for this slow request” possible.
The One Rule
Pass the context. Always. To everything that accepts one.
The entire value of the context package — cancellation, deadlines, distributed tracing — depends on it being threaded through every I/O operation in the call chain. A single point where context is dropped (a goroutine that creates context.Background(), an HTTP client call without the request context) breaks the chain for that branch of execution.
In code review, a function that accepts a context but doesn’t pass it to its I/O calls is always a bug. Flag it.