Every non-trivial Go service needs to answer two questions for any in-progress operation: “should I stop?” and “how long do I have?” The context.Context interface provides both, plus a mechanism for carrying request-scoped data through a call chain.
Contexts form a tree. The root is context.Background(). Each With* function creates a child that inherits its parent’s deadline and cancellation, and adds its own constraint. When a parent is cancelled, all its children are cancelled automatically. This propagation is what makes context the right tool for coordinating distributed work — cancel the root request, and everything spawned from it stops.
For the goroutine patterns that use context see Go goroutines and Go worker pools.
The Four Context Constructors
context.WithCancel — Manual Cancellation
WithCancel returns a child context and a cancel function. Call cancel() to stop all goroutines watching the child:
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always defer — prevents context leak if you forget to call it
go func() {
for {
select {
case <-ctx.Done():
fmt.Println("stopping:", ctx.Err()) // context.Canceled
return
default:
doWork()
}
}
}()
time.Sleep(500 * time.Millisecond)
cancel() // signals all goroutines watching this context to stop
defer cancel() is idiomatic even when you call cancel() explicitly — it ensures the cancellation fires even if the function returns early through an error.
context.WithTimeout — Relative Deadline
WithTimeout cancels the context after a duration. Use it for operations that must complete within a budget:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
WithTimeout(parent, d) is exactly equivalent to WithDeadline(parent, time.Now().Add(d)). Use WithTimeout when thinking in relative durations; use WithDeadline when you have an absolute deadline from an upstream caller.
context.WithDeadline — Absolute Deadline
deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
// Check how much time remains
remaining, _ := ctx.Deadline()
fmt.Println("deadline in:", time.Until(remaining).Round(time.Second))
When a child context has a shorter deadline than the parent, the child’s deadline applies. When the parent’s deadline is shorter, the parent’s applies — you can never extend a deadline by creating a child context.
context.WithValue — Request-Scoped Data
WithValue attaches a key-value pair to a context. Values propagate to all child contexts. Use it only for request-scoped metadata (trace IDs, authenticated user, request ID) — not for optional function parameters:
type contextKey struct{ name string }
var (
requestIDKey = contextKey{"requestID"}
userIDKey = contextKey{"userID"}
)
// Set in middleware
func requestMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := generateRequestID()
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Read anywhere in the call chain
func logEvent(ctx context.Context, msg string) {
reqID, _ := ctx.Value(requestIDKey).(string)
slog.Info(msg, slog.String("request_id", reqID))
}
Use a private unexported type for the key (the contextKey struct above) to prevent collisions with keys from other packages. Using a plain string like context.WithValue(ctx, "userID", id) risks a collision with any other package that uses the same string.
Propagating Context Through a Call Chain
Context is always the first argument, always named ctx. Every function that does I/O, blocks, or spawns goroutines should accept and pass it:
func handleOrder(ctx context.Context, orderID string) (*Order, error) {
// Pass context to every downstream call
user, err := getUser(ctx, orderID)
if err != nil {
return nil, err
}
inventory, err := checkInventory(ctx, orderID)
if err != nil {
return nil, err
}
return processOrder(ctx, user, inventory)
}
func getUser(ctx context.Context, orderID string) (*User, error) {
// ctx propagates — if handleOrder's context is cancelled, this returns immediately
return db.QueryContext(ctx, "SELECT * FROM users JOIN orders ...")
}
When the context is cancelled — because the HTTP request timed out, the client disconnected, or the operator cancelled the job — db.QueryContext returns immediately with ctx.Err(). No manual polling, no additional channels needed.
HTTP: Context Flows from Request to Response
The Go HTTP server creates a context per request, accessible via r.Context(). It’s automatically cancelled when the client disconnects. Always use this context for downstream calls:
func userHandler(w http.ResponseWriter, r *http.Request) {
// r.Context() is already cancelled if the client disconnects
user, err := db.QueryRowContext(r.Context(),
"SELECT id, name FROM users WHERE id = $1",
r.PathValue("id"),
).Scan(&u.ID, &u.Name)
if errors.Is(err, context.Canceled) {
// Client disconnected — no need to send a response
return
}
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
For outgoing HTTP client calls, attach the context to the request with http.NewRequestWithContext:
func callDownstream(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("calling %s: %w", url, err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
If the upstream request context is cancelled while the downstream call is in flight, Do returns immediately with a context error.
Checking Cancellation in Long-Running Loops
Any function that loops over work items should check the context periodically:
func processItems(ctx context.Context, items []Item) error {
for i, item := range items {
// Check before each item, not just at the start
if err := ctx.Err(); err != nil {
return fmt.Errorf("cancelled after %d items: %w", i, err)
}
if err := process(ctx, item); err != nil {
return fmt.Errorf("item %d: %w", i, err)
}
}
return nil
}
ctx.Err() returns nil when the context is still active, context.Canceled when cancelled, and context.DeadlineExceeded when the deadline passed. Checking it at the top of each iteration lets you stop promptly rather than processing thousands of items after the deadline.
Graceful Shutdown with Context
Server shutdown is another key use case. Listen for OS signals, cancel a root context, and let all in-flight operations respect the cancellation:
func main() {
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
Handler: buildRouter(rootCtx),
}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}()
<-rootCtx.Done()
log.Println("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
signal.NotifyContext (Go 1.16+) creates a context that cancels when the OS signal arrives. The 30-second shutdown context gives in-flight requests time to complete before the process exits.
Context Rules
Never store a context in a struct. Pass it as a parameter. Storing a context means it can outlive its intended scope and prevents GC:
// ❌ Don't store context in a struct
type Service struct {
ctx context.Context // wrong
}
// ✅ Pass it as a parameter
func (s *Service) DoWork(ctx context.Context) error { ... }
context.Background() at the top, context.TODO() as a placeholder. Background is for main functions, test setup, and the root of a request tree. TODO signals “I’ll add proper context propagation later” — it’s a marker for incomplete code, caught by go vet in some linters.
Always defer cancel(). Every With* function returns a cancel function. Not calling it leaks the context’s goroutine until the parent is cancelled. defer cancel() ensures cleanup even if you return early.
Diagnosing Context Errors
if err != nil {
switch {
case errors.Is(err, context.Canceled):
// Request was explicitly cancelled — usually client disconnect or manual cancel
case errors.Is(err, context.DeadlineExceeded):
// Timed out — log the deadline and consider increasing it or optimizing the operation
default:
// Real error — log and handle
}
}
errors.Is works through wrapped errors, so fmt.Errorf("query: %w", ctx.Err()) still matches errors.Is(err, context.DeadlineExceeded).
Summary
WithCancel: manual stop — callcancel()when you’re done, alwaysdefer cancel()WithTimeout/WithDeadline: automatic stop after duration or at time — the shorter deadline always wins between parent and childWithValue: request metadata (trace IDs, auth) — use private struct types as keys to avoid collisions- Pass
ctx context.Contextas the first argument to every function that does I/O, blocks, or launches goroutines - Use
r.Context()in HTTP handlers andhttp.NewRequestWithContextin clients — context flows naturally through the request lifecycle - Check
ctx.Err()in loops; useerrors.Is(err, context.DeadlineExceeded)to distinguish timeout from cancellation
Comments