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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // already has cancellation from net/http

    results := make(chan Result, 2)
    errs    := make(chan error, 2)

    go func() {
        result, err := fetchFromServiceA(ctx)
        if err != nil { errs <- err; return }
        results <- result
    }()

    go func() {
        result, err := fetchFromServiceB(ctx)
        if err != nil { errs <- err; return }
        results <- result
    }()

    // wait for both or cancellation
    var combined []Result
    for i := 0; i < 2; i++ {
        select {
        case r := <-results:
            combined = append(combined, r)
        case err := <-errs:
            http.Error(w, err.Error(), 500)
            return
        case <-ctx.Done():
            // client disconnected or timeout
            http.Error(w, ctx.Err().Error(), 499)
            return
        }
    }
    // ...
}

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 typeExampleWhy context is correct
Trace/spanopentelemetry.SpanFromContext(ctx)Every function participates in tracing without accepting a Span param
Request IDrequestIDFromContext(ctx)Logging correlation, no business logic depends on it
Auth principalprincipalFromContext(ctx)Security context, not domain logic input

Incorrect uses — using context as a grab-bag to avoid fixing function signatures:

1
2
3
4
// Bad: business logic hidden in context
ctx = context.WithValue(ctx, "instrumentID", "EURUSD")
// ...100 function calls later...
instrumentID := ctx.Value("instrumentID").(string) // magic! where did this come from?

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func processOrder(ctx context.Context, order Order) error {
    ctx, span := otel.Tracer("trading").Start(ctx, "processOrder")
    defer span.End()

    span.SetAttributes(
        attribute.String("instrument", order.Instrument),
        attribute.Int64("quantity", order.Quantity),
    )

    if err := validateOrder(ctx, order); err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return err
    }

    return submitOrder(ctx, order) // ctx carries the span into submitOrder
}

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:

1
2
req, _ := http.NewRequestWithContext(ctx, "POST", url, body)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

For inbound HTTP requests, extract it:

1
2
3
4
5
6
func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

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.