context.Context, Cancellation & Deadlines
context.Context propagates cancellation, deadlines, and request-scoped values through call chains. Every long-running operation should accept a Context and select on ctx.Done(). The universal cancellation primitive in modern Go.
Why context.Context
In a Go service, a single user request may spawn N goroutines: one per RPC, one per DB query, one per fan-out worker. When the user disconnects (or a timeout fires), all of them must be cancelled, quickly. Without a unified mechanism, each goroutine has its own cancellation logic, and forgetting to cancel one becomes a leak.
context.Context is the unified mechanism. It's a value that:
- Carries a Done channel, closes when cancelled.
- Carries an Err, surfaces why (cancelled, deadline exceeded).
- Forms a tree, child contexts inherit parent cancellation.
- Carries values, for cross-cutting concerns like trace IDs.
The two rules of context
- Context is the first parameter to every function that does I/O or has a long-running goroutine.
- Always
defer cancel()after creating a cancellable context, even when manual cancellation is "not expected".
The cancellation contract
A function that accepts ctx promises to:
- Check
ctx.Done()at all blocking points. - Return
ctx.Err()when cancelled (don't wrap or hide it). - Pass
ctxdown to any function it calls that takes one.
Functions that don't honor this break the chain. Common bug: a wrapper function that takes ctx but doesn't pass it to its inner call. The cancellation signal never reaches the operation that should respect it.
When values become an antipattern
context.WithValue is appropriate for:
- Request IDs, trace IDs, span IDs.
- Authenticated user (lookup data).
- Cross-cutting middleware data.
It's an antipattern for:
- Configuration (use struct fields).
- Domain data (pass it explicitly).
- Anything that's not request-scoped.
The "magic context" smell If a function pulls 5 different values out of context, the API is hidden. Reading it doesn't reveal what it depends on. Better: explicit parameters for domain data; context only for cross-cutting concerns.
Production patterns to know
- errgroup: cancel siblings on first error.
- Per-RPC context: every gRPC handler receives a context tied to the call's deadline.
- Layered timeouts: outer ctx with 30s deadline; per-DB-call inner with 5s. Whichever fires first cancels.
- Cleanup with cancel:
defer cancel()releases the cancellation goroutine even on success path.
Primitives by language
- context.Background / TODO
- context.WithCancel / WithTimeout / WithDeadline / WithValue
- ctx.Done / ctx.Err / ctx.Value
- errgroup.WithContext (cancellation on first error)
Implementation
Convention: ctx is always the first parameter. Every blocking operation (HTTP, DB, sleep) should accept and respect it. Cancellation flows from the top.
1 package main
2
3 import (
4 "context"
5 "net/http"
6 "time"
7 )
8
9 func fetchUser(ctx context.Context, id string) (*User, error) {
10 // ctx propagates to the HTTP call
11 req, _ := http.NewRequestWithContext(ctx, "GET", "https://api/users/"+id, nil)
12 resp, err := http.DefaultClient.Do(req)
13 if err != nil {
14 return nil, err // includes context.Canceled / DeadlineExceeded
15 }
16 defer resp.Body.Close()
17 return decodeUser(resp.Body)
18 }
19
20 func main() {
21 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
22 defer cancel() // ALWAYS defer cancel
23 user, err := fetchUser(ctx, "42")
24 }Most basic form: parent calls cancel() to signal cancellation. Children select on ctx.Done() and exit. Used when cancellation is event-driven (user clicked stop, parallel sibling found result first).
1 package main
2
3 import (
4 "context"
5 "fmt"
6 )
7
8 func main() {
9 ctx, cancel := context.WithCancel(context.Background())
10
11 go func() {
12 for {
13 select {
14 case <-ctx.Done():
15 fmt.Println("worker stopping:", ctx.Err())
16 return
17 default:
18 doWork()
19 }
20 }
21 }()
22
23 // Some condition triggers cancellation
24 time.Sleep(time.Second)
25 cancel()
26 }Cancels after duration even if no one calls cancel. Useful for "this operation must complete within N seconds." Always defer cancel anyway, releases the timer goroutine.
1 package main
2
3 import (
4 "context"
5 "time"
6 )
7
8 func slowOp(ctx context.Context) error {
9 select {
10 case <-ctx.Done():
11 return ctx.Err() // context.DeadlineExceeded
12 case <-time.After(10 * time.Second):
13 return nil // operation completed
14 }
15 }
16
17 func main() {
18 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
19 defer cancel()
20
21 if err := slowOp(ctx); err != nil {
22 // err == context.DeadlineExceeded
23 }
24 }WithCancel, WithTimeout etc. create a child Context. When the parent cancels, all children cancel automatically. Build a tree mirroring the call structure.
1 package main
2
3 import (
4 "context"
5 "time"
6 )
7
8 func main() {
9 parent, parentCancel := context.WithCancel(context.Background())
10 defer parentCancel()
11
12 // Child with shorter deadline
13 child, childCancel := context.WithTimeout(parent, 2*time.Second)
14 defer childCancel()
15
16 go func() {
17 <-child.Done()
18 // child.Err() is whichever fired first:
19 // parent cancelled → context.Canceled
20 // timeout reached → context.DeadlineExceeded
21 }()
22 }errgroup.WithContext creates a Context that's cancelled when any goroutine in the group returns a non-nil error. Standard pattern for "fan-out work, cancel all on first failure."
1 package main
2
3 import (
4 "context"
5
6 "golang.org/x/sync/errgroup"
7 )
8
9 func processAll(ctx context.Context, items []Item) error {
10 g, ctx := errgroup.WithContext(ctx)
11 for _, item := range items {
12 item := item
13 g.Go(func() error {
14 return process(ctx, item) // ctx cancels on any sibling error
15 })
16 }
17 return g.Wait() // returns first error
18 }context.WithValue carries request-scoped values (request ID, auth user). Use sparingly, abuse leads to magic action-at-a-distance. Best for cross-cutting concerns: tracing IDs, request user, etc.
1 package main
2
3 import "context"
4
5 type ctxKey string
6
7 const (
8 requestIDKey ctxKey = "request-id"
9 userKey ctxKey = "user"
10 )
11
12 func handler(ctx context.Context) {
13 ctx = context.WithValue(ctx, requestIDKey, generateID())
14 ctx = context.WithValue(ctx, userKey, currentUser())
15 processOrder(ctx)
16 }
17
18 func processOrder(ctx context.Context) {
19 rid, _ := ctx.Value(requestIDKey).(string)
20 log.Printf("[%s] processing order", rid)
21 }Key points
- •Context flows through the call graph, pass as first parameter to every function that may block
- •WithCancel: parent function cancels manually
- •WithTimeout / WithDeadline: auto-cancellation after duration
- •ctx.Done() returns a channel that closes on cancellation
- •Always defer cancel() to release resources, even when not strictly needed
Follow-up questions
▸Why is ctx the first parameter, not last?
▸When should context NOT be used?
▸What's context.Background vs context.TODO?
▸Memory leak, context.WithCancel without calling cancel?
Gotchas
- !Forgetting `defer cancel()` → goroutine leak (the cleanup goroutine sticks around)
- !Storing context as a struct field; convention says pass it explicitly
- !Putting too much in context.Value, easy to abuse for global state
- !Mixing cancellation: ctx is cancelled but inner code didn't check ctx.Done() → operation continues
- !context.Background() in a function that should take a ctx, silently disables cancellation