Context Propagation Patterns in Go Services
context.Context is one of the most important things to get right in Go services, and one of the most commonly misused. I've reviewed a lot of Go code over the past few years, and the same mistakes come up repeatedly: contexts stored in structs, deadlines not set on external calls, context values used as a general-purpose parameter bag. Let me go through what context is actually for and how to use it correctly.
What context.Context Is Really For
Context has three purposes:
- Cancellation propagation: When a request is cancelled (client disconnects, parent context cancelled), all work spawned from that request should stop.
- Deadlines and timeouts: Set a deadline on a context, and all operations using that context know they must finish by that time.
- Request-scoped values: Carry values that are logically part of the request — request ID, authenticated user, trace ID — without threading them through every function signature.
That's it. Context is not a global variable substitute. It's not a way to pass optional parameters. It's not a place to store anything that could reasonably be a function argument.
The Cardinal Rules
Don't store contexts in structs. This is in the Go documentation and it matters. A context represents a single unit of work — a single request, a single operation. A struct typically outlives any individual request. If you store a context in a struct, you end up with an object whose context has been cancelled hours ago but the object is still alive.
// Wrong
type Service struct {
ctx context.Context // Don't do this
db *sql.DB
}
// Right: pass ctx as first argument to methods
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
return s.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id).Scan(...)
}
Always pass context as the first argument. This is the Go convention, and you should follow it uniformly. Every function that does I/O, spawns goroutines, or calls other services takes ctx context.Context as its first parameter. Every time.
Never pass a nil context. If you don't have a context, use context.Background() or context.TODO(). TODO signals that you intend to wire up a real context later — it's honest about technical debt. nil will cause a panic.
Cancellation Propagation Through Call Chains
When a context is cancelled, every function using that context should stop work and return. The database/sql, net/http, and most well-written Go libraries respect context cancellation. Your own code needs to as well.
func processItems(ctx context.Context, items []Item) error {
for _, item := range items {
// Check for cancellation before each unit of work
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := processItem(ctx, item); err != nil {
return err
}
}
return nil
}
The select with ctx.Done() and default is a non-blocking check. If the context is already cancelled when we reach it, we return immediately. If not, we proceed. For expensive operations or loops, this check is how you make your code respectful of cancellation.
context.WithTimeout for External Calls
Every call to an external system — database, HTTP endpoint, gRPC service, KMS API — should have a timeout. Without one, a slow or unresponsive dependency can hang your goroutines indefinitely.
func fetchUserFromDB(ctx context.Context, db *sql.DB, id string) (*User, error) {
// Don't trust the caller to have set a timeout; set one for this operation
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // Always defer cancel to release resources
var u User
err := db.QueryRowContext(ctx, "SELECT id, email FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Email)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("db query timed out for user %s", id)
}
return nil, err
}
return &u, nil
}
The defer cancel() is not optional. If you create a WithTimeout context and don't cancel it, the resources associated with it aren't released until the deadline fires. In a hot path, this leaks memory.
Note that context.WithTimeout creates a child context. If the parent context is already cancelled or past its deadline, the child is cancelled immediately. The tightest deadline wins.
Context Values: What to Store and What Not To
Context values are retrieved with ctx.Value(key) and stored without type safety. This makes them easy to misuse. The rule is: only store values that are cross-cutting concerns for the request, not regular parameters.
Store:
- Request ID / trace ID
- Authenticated user/principal identity
- Distributed tracing span
Don't store:
- Database connections (pass them explicitly or use a pool)
- Configuration values (pass them at construction time)
- Optional parameters for functions (add them to the function signature)
For type safety, always use unexported key types:
type contextKey int
const (
requestIDKey contextKey = iota
authPrincipalKey
)
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
Using an unexported type as the key prevents key collisions with other packages. Using a typed accessor function (RequestIDFromContext) keeps the type assertion in one place.
Writing a Request ID Middleware
Here's a complete example of HTTP middleware that injects a request ID into the context:
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Use the incoming header if present (for tracing across services),
// otherwise generate a new ID
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
// Inject into context
ctx := WithRequestID(r.Context(), requestID)
// Set on response header so the caller can correlate
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func generateRequestID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return fmt.Sprintf("%x", b)
}
Now any handler in the chain can call RequestIDFromContext(r.Context()) to get the request ID for logging or tracing, without it being passed as a function parameter.
Detecting Cancellation in Goroutines
When you launch goroutines to do parallel work, they need to respect the parent context too:
func fanOut(ctx context.Context, ids []string) ([]Result, error) {
results := make(chan Result, len(ids))
errc := make(chan error, 1)
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(id string) {
defer wg.Done()
result, err := fetchOne(ctx, id)
if err != nil {
select {
case errc <- err:
default:
}
return
}
results <- result
}(id)
}
go func() {
wg.Wait()
close(results)
}()
var all []Result
for r := range results {
all = append(all, r)
}
select {
case err := <-errc:
return nil, err
default:
return all, nil
}
}
The ctx passed to each goroutine is the same context as the caller. If that context is cancelled, fetchOne will see it and return an error. The goroutines don't need to check ctx.Done() explicitly if the functions they call already do.
Getting context right is largely about discipline. Once it's consistent across a codebase, debugging concurrent services becomes significantly easier — you can see exactly what work is in flight, what deadlines apply, and what triggered a cancellation.