Cancellation with context
When a user closes their browser tab, the server should stop the database query it started on their behalf. When a request has a two-second budget, every call it makes should respect that deadline. The context package carries this kind of signal — cancellation, deadlines and a few request-scoped values — through your program. This lesson covers creating contexts, cancelling and timing out work, writing functions that respect a context, and the conventions around them.
What a context is
A context.Context is passed down through function calls. Its Done() method returns a channel that's closed when the work should stop, and Err() says why. Contexts form a tree: cancelling a parent cancels all its children.
| Function | Creates a context that… |
|---|---|
context.Background() | Is never cancelled: the root, used in main and tests |
context.WithCancel(parent) | Is cancelled when you call the returned cancel function |
context.WithTimeout(parent, d) | Is cancelled automatically after duration d |
context.WithDeadline(parent, t) | Is cancelled automatically at time t |
context.WithValue(parent, key, val) | Carries a request-scoped value |
Respecting cancellation
A function that does slow or repeated work should watch ctx.Done() and stop when it closes, returning ctx.Err():
package main import ( "context" "fmt" "time" ) func processItems(ctx context.Context, n int) (int, error) { done := 0 for range n { select { case <-ctx.Done(): return done, ctx.Err() // stop early case <-time.After(50 * time.Millisecond): // one item's worth of work done++ } } return done, nil } func main() { ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(125 * time.Millisecond) cancel() // e.g. the user pressed "stop" }() n, err := processItems(ctx, 10) fmt.Println("processed", n, "items, error:", err) }
processed 2 items, error: context canceled
Timeouts
WithTimeout cancels the context automatically. Always call the returned cancel function when you're done (usually with defer) — it releases the timer even if the work finishes early:
package main import ( "context" "errors" "fmt" "time" ) func slowQuery(ctx context.Context, d time.Duration) (string, error) { select { case <-time.After(d): return "42 rows", nil case <-ctx.Done(): return "", fmt.Errorf("query: %w", ctx.Err()) } } func main() { for _, d := range []time.Duration{20 * time.Millisecond, 300 * time.Millisecond} { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) res, err := slowQuery(ctx, d) cancel() switch { case errors.Is(err, context.DeadlineExceeded): fmt.Println("too slow:", err) case err != nil: fmt.Println("failed:", err) default: fmt.Println("result:", res) } } }
result: 42 rows too slow: query: context deadline exceeded
ctx.Err() is context.Canceled after a manual cancel and context.DeadlineExceeded after a timeout, so callers can tell the two apart with errors.Is. go vet warns if a cancel function is never called.
Contexts in the standard library
Most standard library operations that can block accept a context: http.NewRequestWithContext, db.QueryContext, net.Dialer.DialContext, exec.CommandContext. In an HTTP server, each request has a context — r.Context() — that's cancelled when the client disconnects. Pass it on, and abandoned requests stop using resources automatically.
Cancelling a whole group of goroutines
Because cancelling a parent cancels every child, one cancel call can stop many goroutines. Here the first worker to find the target cancels the rest:
package main import ( "context" "fmt" "sync" "sync/atomic" "time" ) func search(ctx context.Context, from, to, target int, found func(int)) { for i := from; i < to; i++ { select { case <-ctx.Done(): return default: } if i == target { found(i) return } time.Sleep(time.Millisecond) } } func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() var stopped atomic.Int32 var wg sync.WaitGroup for w := range 4 { wg.Go(func() { search(ctx, w*1000, (w+1)*1000, 2050, func(n int) { fmt.Println("worker", w, "found", n) cancel() // stop all the others }) stopped.Add(1) }) } wg.Wait() fmt.Println(stopped.Load(), "workers stopped; reason:", ctx.Err()) }
worker 2 found 2050 4 workers stopped; reason: context canceled
Cancellation causes
WithCancelCause and WithTimeoutCause let you record why a context was cancelled; context.Cause(ctx) retrieves it:
package main import ( "context" "errors" "fmt" ) var ErrQuotaExceeded = errors.New("daily quota exceeded") func main() { ctx, cancel := context.WithCancelCause(context.Background()) cancel(ErrQuotaExceeded) fmt.Println(ctx.Err()) fmt.Println(context.Cause(ctx)) }
context canceled daily quota exceeded
Values: use sparingly
WithValue attaches a value — a request ID, the authenticated user — for code further down to read. Use your own unexported key type so packages can't collide, and only use it for request-scoped data that passes through APIs, never for ordinary function parameters:
package main import ( "context" "fmt" ) type ctxKey int const requestIDKey ctxKey = 0 func handle(ctx context.Context) { logLine(ctx, "loading cart") } func logLine(ctx context.Context, msg string) { id, _ := ctx.Value(requestIDKey).(string) fmt.Printf("[req %s] %s\n", id, msg) } func main() { ctx := context.WithValue(context.Background(), requestIDKey, "a1b2c3") handle(ctx) }
[req a1b2c3] loading cart
Conventions
- Pass the context as the first parameter, named
ctx:func Fetch(ctx context.Context, url string). - Don't store a context in a struct; pass it to each call that needs it.
- Never pass a nil context. If you're unsure which to use, use
context.TODO()and come back to it. - Call
cancelfunctions, typically withdefer cancel()right after creating the context.
Try it yourself
Download three "files" with a total time budget of 150 ms. Which ones finish? Change the budget and see.
downloaded a.txt downloaded b.txt failed: c.txt: context deadline exceeded