19.6 Propagating context Through Call Chains
Right, so you’ve created a context.Context at the top of your call chain—maybe from an HTTP request or a user-driven command. Pat yourself on the back. But that context is utterly useless if it just sits there. Its entire purpose is to be a baton passed through a relay race of function calls, carrying the crucial signals of cancellation and deadlines down the chain. If you drop the baton, your goroutines in the back won’t know the race is over, and they’ll just keep running, pointlessly burning CPU cycles and leaking memory like a sieve. Let’s make sure you’re not that runner.
The rule is simple, almost dogmatic: Any function that can block or take a non-trivial amount of time must take a context.Context as its first argument. This isn’t a suggestion; it’s the contract for working gracefully with cancellation in Go.
Here’s the canonical signature you should be using:
func myOperation(ctx context.Context, arg1 string, arg2 int) (Result, error) {
// ... work that might need to be cancelled ...
}
Notice the ctx is always first. This is a universally accepted convention, making it easy to spot these functions. Now, let’s see how to actually use the thing you’re passing down.
Listening to the Context’s Done Channel
The primary way you “hear” a cancellation signal is by listening on the ctx.Done() channel. This is a read-only channel that is closed when the context should be canceled. When it’s closed, it’s your function’s job to stop what it’s doing, clean up any resources, and return—almost always with a ctx.Err().
Here’s a realistic example: a function that queries a database. Notice how we use a select statement to either wait for the query or for the cancellation signal.
func getUserProfile(ctx context.Context, userID string) (*Profile, error) {
// Prepare the SQL statement (this is fast, doesn't need ctx)
stmt, err := db.PrepareContext(ctx, "SELECT name, email FROM users WHERE id = $1")
if err != nil {
return nil, err
}
defer stmt.Close()
// Now, execute the query, which is a blocking operation.
// We need to be able to cancel this if the client disconnects.
var profile Profile
row := stmt.QueryRowContext(ctx, userID) // We pass ctx to the DB driver too!
// This is a potentially slow operation we need to make interruptible.
err = row.Scan(&profile.Name, &profile.Email)
if err != nil {
return nil, err
}
// Simulate some more, potentially long, post-processing.
// This is where YOU must listen to the context.
select {
case <-time.After(500 * time.Millisecond): // Some CPU-heavy work
profile.AvatarURL = generateGravatar(profile.Email)
case <-ctx.Done(): // The parent context was cancelled!
return nil, ctx.Err() // This will be context.DeadlineExceeded or context.Canceled
}
return &profile, nil
}
The key here is that we pass the ctx all the way down to the database driver call (QueryRowContext), because the driver knows how to interrupt its own network I/O. Then, for our own logic after the query, we explicitly check ctx.Done() to avoid doing work that’s no longer needed.
The Critical Difference Between Canceled and DeadlineExceeded
When you get an error from ctx.Err(), it’s important to know why your work was stopped. Was it an explicit cancellation (e.g., a client closed a connection) or did a timer simply run out? The context tells you this.
context.Canceled: Someone called thecancel()function we got fromcontext.WithCancel.context.DeadlineExceeded: The deadline we set withcontext.WithTimeoutorcontext.WithDeadlinehas passed.
In 99% of cases, you can just return the error as-is. But sometimes you might want to log them differently or handle a graceful shutdown differently from a hard timeout.
The Pitfall of Creating New, Unlinked Contexts
Here’s the most common mistake I see, and it completely defeats the purpose of all this. Look at this code and tell me what’s wrong:
func badHandler(ctx context.Context) {
// This is a crime against concurrency. Don't do this.
newCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result := callSomeService(newCtx) // This newCtx is NOT linked to the parent ctx!
}
See it? We’ve completely ignored the incoming ctx from the request. We’ve created a brand-new, orphaned context. If the original HTTP request is canceled by the user after 1 second, badHandler will blissfully ignore it and keep callSomeService running for a full 5 seconds, wasting resources. The correct pattern is to derive a new context from the existing one.
Deriving Contexts Correctly
You almost never want to use context.Background() or context.TODO() anywhere but in main() or at the very top of a call chain. Instead, derive a new context with a more specific purpose.
func goodHandler(ctx context.Context) {
// Derive a new context from the parent, with a stricter timeout.
// This new context will be canceled if EITHER the parent is canceled
// OR the 2-second timeout elapses.
queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // Always defer the cancel to avoid context leaks, even if you use a timeout!
result, err := callSomeService(queryCtx)
if err != nil {
log.Printf("Service call failed: %v", err) // This could be a timeout OR a client cancel
return
}
// Use the result
}
This is the correct way. Now, callSomeService will be canceled after 2 seconds or as soon as the parent ctx is canceled, whichever happens first. The contexts are properly linked. This is the propagation magic we’re after. You’re not just passing a context; you’re building a tree of cancellation signals, and that’s what makes complex, concurrent applications manageable.