AlgoMaster Logo

Context Propagation & Best Practices

High Priority17 min readUpdated June 6, 2026

This chapter is about wiring contexts through real systems: HTTP handlers, database queries, fan-out goroutines, and the cross-package call chains in between. The mechanics are simple. The discipline is what makes the difference between a service that cleans up after itself and one that leaks connections, goroutines, and memory.

The Propagation Rule

Every function in a call chain that does I/O, blocks on a channel, sleeps, or calls another context-aware function must accept a context.Context as its first parameter. The parameter is named ctx, not c or context. You pass it down to every downstream call, unchanged unless you wrap it with WithCancel, WithTimeout, or WithDeadline for a sub-operation.

This is a one-line rule, but it's the one that holds the whole pattern up. If even one function in the chain forgets to take or pass ctx, cancellation stops there. A 200ms deadline at the top of the request can't reach a 30-second database query if the function between them dropped the context.

Here's a three-level call chain through a checkout flow. The HTTP handler accepts the request context, passes it to the order service, which passes it to the inventory client, which uses it on the actual blocking call.

The 200ms deadline created at the top flows through handleCheckout, placeOrder, and checkInventory. Every blocking operation respects it. If the inventory lookup had taken 300ms instead of 50ms, ctx.Done() would have fired and the chain would unwind with context.DeadlineExceeded.

The diagram below shows how ctx threads through the call stack. The same value reaches every function, which is what makes a single cancel() or deadline expiration tear down everything below it.

A few conventions:

  • ctx is always the first parameter.
  • The parameter is always named `ctx`. The Go standard library, go vet, and code review will expect this.
  • Don't make ctx optional. If a function needs it, it needs it. Callers that don't have one yet pass context.TODO().
  • Don't pass a nil context. context.Background() and context.TODO() exist for that reason.

Checking ctx.Err() and Surfacing Cancellation

After any blocking operation that returns an error, callers up the chain often want to know whether the error was a cancellation, a deadline, or something else. The context package defines two sentinel errors for this: context.Canceled and context.DeadlineExceeded. You compare against them with errors.Is from the errors package, which traces through wrapped errors.

When you wrap an error with fmt.Errorf("...: %w", err), the original sentinel is still reachable by errors.Is. This is how a five-level deep cancellation reads cleanly at the top of the stack.

Note three things. First, the deadline is short enough (20ms) that the 100ms work can't finish, so ctx.Done() fires first. Second, the wrapper fmt.Errorf("search %q: %w", query, ctx.Err()) preserves the sentinel underneath. Third, the caller uses errors.Is to ask which sentinel it was without caring how deep the wrapping goes.

A subtle but common pattern: after a function returns from a long-running step, check ctx.Err() even if the step itself reported success. The context might have been cancelled after the work finished but before you act on its result.

The fetch ignored the context, returned a price, but by the time control flowed back to main, the deadline had already passed. The post-call ctx.Err() check catches that and prevents the program from acting on stale data. This is defensive but cheap.

HTTP Server Integration

Every *http.Request carries a context.Context accessible via r.Context(). The Go runtime ties that context to the request lifecycle: when the client disconnects, when the handler's response has been written, or when the server is shutting down, the request context is cancelled. Your handler doesn't have to create or manage this context. It just needs to pass r.Context() down to every blocking call.

The handler pulls r.Context() and passes it to lookupOrder. If the client closes the connection mid-request, r.Context() gets cancelled, the select inside lookupOrder fires <-ctx.Done(), and the handler returns a 504. No goroutines are stranded waiting on work nobody's listening for.

The lifecycle is automatic, but only as long as you actually use r.Context(). Handlers that ignore it and call context.Background() for downstream work disable client-disconnect detection. The server keeps doing work for clients who already gave up, which is a textbook scalability bug.

If you need a tighter deadline than the client's (say, you want to give up on a slow database after 200ms even if the client is willing to wait longer), wrap the request context:

The child context inherits cancellation from the request and adds its own deadline. Either the client disconnect or your 200ms timeout will end it, whichever comes first.

HTTP Client Integration

The flip side is making outbound HTTP calls. http.NewRequestWithContext attaches a context to the request, and the standard library aborts the in-flight call when the context fires. Without it, a stuck server can hang your goroutine forever, no matter what you do at higher levels.

When the 50ms deadline fires, http.DefaultClient.Do returns a wrapped *url.Error whose underlying cause is context.DeadlineExceeded. errors.Is finds it through the wrapping. The underlying TCP connection is closed, freeing the socket immediately.

A common pitfall: setting http.Client{Timeout: ...} and assuming that's enough. The Timeout field covers the whole request including reading the body, and it's not inheritable through a call chain. Use context for chain-aware cancellation; Timeout is fine as a belt-and-suspenders backstop.

database/sql Integration

The database/sql package has context-aware variants of every blocking method: QueryContext, QueryRowContext, ExecContext, PrepareContext, BeginTx. Use them. The non-context versions (Query, Exec, etc.) still exist for backwards compatibility, but they can't be cancelled, which means a query that takes ten minutes will block your goroutine for ten minutes even if the caller has already given up.

When you cancel a context attached to a SQL operation, the driver sends a cancel signal to the database (the postgres driver issues a pg_cancel_backend on the server side, for example), and the operation returns promptly with context.Canceled or context.DeadlineExceeded.

The example doesn't open a real database (that requires a driver and a running server), but the signature is what matters. The same shape applies to ExecContext for inserts and updates:

For transactions, db.BeginTx(ctx, opts) ties the transaction to the context, and any statements run on that *sql.Tx should use their context-aware variants too. If the context fires mid-transaction, the driver rolls back automatically when you call tx.Rollback() in a deferred cleanup.

errgroup and Fan-Out Cancellation

golang.org/x/sync/errgroup is the standard tool for fan-out work where any failure should cancel the remaining work. errgroup.WithContext(parent) returns a *Group plus a derived context. When any goroutine launched via g.Go returns a non-nil error, the derived context is cancelled, and the other goroutines see it through ctx.Done(). g.Wait() returns the first non-nil error.

A common checkout pipeline runs three downstream checks in parallel: inventory, fraud, payment authorization. If any one fails, the others should give up. Without errgroup, you'd hand-roll this with a sync.WaitGroup, a shared error variable, a mutex, and a context.WithCancel. With errgroup, it's a dozen lines.

The fraud check returns an error at 30ms. Inventory and payment auth would normally take 80ms and 100ms, but as soon as the fraud check fails, the errgroup cancels gctx. The two pending operations see <-ctx.Done(), return context.Canceled, and g.Wait() returns the original fraud error (not one of the cancellations: errgroup returns the first non-nil error, and the cancellations are downstream effects).

Two notes on the API that catch people. First, gctx is what the goroutines must use, not the outer ctx. Using the outer ctx would defeat the fan-out cancellation: the siblings wouldn't see the cancel signal from the failing peer. Second, g.Go does not block; it spawns a goroutine. g.Wait() blocks until all goroutines finish. If you forget to call g.Wait(), goroutines may keep running after runCheckout returns, which is a leak.

For bounded fan-out, g.SetLimit(n) (added in Go 1.18 to golang.org/x/sync) caps the number of concurrent goroutines. Calls to g.Go block when the limit is reached, which gives you a worker-pool-like behavior with one line of setup.

Common Mistakes and Anti-Patterns

Most context bugs fall into a handful of patterns. Recognizing them makes them easy to catch in code review.

Storing ctx in a Struct Field

The Go documentation says it plainly: "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." A stored context outlives the operation it was created for. Workers that use s.ctx will keep using the long-dead request's context, including its already-fired cancellation, or worse, its still-live deadline that doesn't apply to the new operation.

The rare exception is a long-lived server that's instantiated with a context.Background()-derived "shutdown" context, used only to signal the whole server should stop. Even there, most teams pass the context explicitly to lifecycle methods (Start(ctx), Shutdown(ctx)) instead of stashing it.

Passing a nil Context

A nil context panics inside the standard library on the first method call (Done(), Err(), etc.). When you genuinely don't have a context yet, use context.TODO(). It behaves like context.Background() (never cancelled, no deadline), but its name signals "this needs to be plumbed through later" and static analyzers can warn on it.

Ignoring the cancel Function

WithCancel, WithTimeout, and WithDeadline always return a cancel function. You must call it, even if the timer fires first. Not calling it leaks the parent-child link and (for timeout/deadline) the underlying timer. go vet reports this as the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak. The canonical idiom is defer cancel() immediately after the call:

Forgetting defer cancel()

A close cousin to the above: you capture cancel but only call it on the success path, forgetting that an early return skips it.

defer cancel() makes this immune to control-flow accidents. Put it on the line right after the WithTimeout and forget about it.

Using context.Background() Deep in a Call Stack

A function that does I/O should accept ctx from its caller, not invent one. Inventing a fresh context.Background() mid-stack means cancellation and deadlines from above don't reach the operation. The fix is to add ctx context.Context as the first parameter and propagate.

context.WithValue for Required Configuration

context.WithValue is for request-scoped values that cross API boundaries, like request IDs, auth tokens, or trace spans. It is not a substitute for explicit function parameters. If a function needs a database handle, pass db *sql.DB. If it needs a logger, pass log *slog.Logger. Don't stash these in context and pull them out by string key.

The reason is twofold. First, context values are untyped at the API level (Value(key any) any), which means callers can't tell from the signature what context expects. Second, it makes refactoring brittle: rename a key, and every consumer breaks at runtime instead of compile time.

Best Practices Summary

A condensed checklist you can keep handy during code review.

DoDon't
Take ctx context.Context as the first parameter of any function that does I/O or blocksPass ctx as the second or later parameter, or omit it
Name the parameter ctxUse c, context, or other names
Pass ctx down to every downstream context-aware callSubstitute context.Background() mid-stack
Use context.TODO() when you genuinely don't have a context yetPass nil
defer cancel() immediately after WithCancel/WithTimeout/WithDeadlineDiscard cancel with _ or call it only on the success path
Check errors.Is(err, context.DeadlineExceeded) and context.Canceled to surface the causeCompare with == (which breaks under wrapping)
Use r.Context() in HTTP handlers and pass it downMake HTTP handlers call context.Background()
Use http.NewRequestWithContext for outbound HTTP callsRely only on http.Client.Timeout for chain-aware cancellation
Use *Context variants in database/sql (QueryContext, ExecContext, BeginTx)Use the non-context variants (Query, Exec) in services that care about cancellation
Use errgroup.WithContext for fan-out with first-error-winsHand-roll a WaitGroup + shared error variable + manual cancel for the same shape
Pass ctx as a function argumentStore ctx in a struct field
Use WithValue only for request-scoped metadata (request IDs, trace spans)Use WithValue for required dependencies or business inputs

A handful of these are enforced by tools. go vet flags lostcancel (missing cancel call) and certain misuses of context.Background() in handlers. Many teams add the contextcheck linter (from golangci-lint) to require context propagation through their call graphs. Enabling linters early is cheap, and they catch the kinds of mistakes that only surface as production leaks.

Quiz

Context Propagation Quiz

10 quizzes