Context and Cancellation in Go: A Practical Guide
Every goroutine you spawn is a promise that something will eventually stop. If you don’t control when that happens, you leak intention: your program keeps doing work nobody asked for, long after the caller moved on.
Go’s context package is the standard answer. But like most standard answers, it’s easy to use poorly and hard to use well. This builds on the channel patterns from the worker pool article and the pipeline article: once goroutines compose, cancellation becomes part of the design, not a cleanup detail.
The Problem: Orphaned Work
Imagine a handler that queries three databases in parallel:
func handleRequest(w http.ResponseWriter, r *http.Request) { var wg sync.WaitGroup wg.Add(3)
go func() { defer wg.Done(); queryDB1() }() go func() { defer wg.Done(); queryDB2() }() go func() { defer wg.Done(); queryDB3() }()
wg.Wait() // ... respond}What happens when the client disconnects after 50ms? The goroutines keep running. The databases keep working. Resources burn for a request that will never be answered.
This is the orphaned work problem. And it’s everywhere in concurrent code.
What Context Gives You
context.Context is a request-scoped value that carries:
- Cancellation signals — tell goroutines to stop
- Deadlines — automatic cancellation after a timeout
- Key-value pairs — request-scoped metadata (trace IDs, user info)
The key insight: context flows down. You create it at the top of a request, pass it to every function and goroutine, and everything respects the same lifecycle.
The Three Flavors
1. context.Background() — The Root
Use this at the top level: main, test setup, initialization. It never cancels. Everything else derives from it.
ctx := context.Background()2. context.WithCancel() — Manual Control
When you need to say “stop now”:
ctx, cancel := context.WithCancel(context.Background())defer cancel() // always call this
// pass ctx to goroutines// call cancel() when you're doneThe cancel function is idempotent. Call it once, twice, a hundred times—same result. This is important for defer patterns.
3. context.WithTimeout() / WithDeadline() — Time-Bound
When you need to say “stop after 2 seconds”:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()The context automatically cancels when the deadline hits. You still call cancel() in defer to release resources early if you finish sooner.
Listening for Cancellation
A context doesn’t stop goroutines by force. It asks politely. You have to listen:
func queryWithContext(ctx context.Context, db *sql.DB) error { rows, err := db.QueryContext(ctx, "SELECT ...") if err != nil { return err } defer rows.Close()
for rows.Next() { select { case <-ctx.Done(): return ctx.Err() // context cancelled default: // process row } } return rows.Err()}The ctx.Done() channel closes when cancellation happens. Checking it in a select gives your goroutine a clean exit path.
The Pattern: Per-Request Context
In HTTP handlers, the request already carries a context:
func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // use this!
// add timeout ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel()
result, err := service.Process(ctx, r.Body) if err != nil { if errors.Is(err, context.DeadlineExceeded) { http.Error(w, "timeout", http.StatusGatewayTimeout) return } http.Error(w, err.Error(), http.StatusInternalServerError) return }
json.NewEncoder(w).Encode(result)}Notice how we derive from r.Context() instead of context.Background(). This way, if the client disconnects, our timeout context inherits that cancellation.
Propagation: The Golden Rule
Every function that does I/O or spawns goroutines should take context.Context as its first parameter.
This is the convention. Follow it even when you don’t think you need it. The caller knows their constraints better than you do.
Bad:
func FetchUser(userID string) (*User, error)Good:
func FetchUser(ctx context.Context, userID string) (*User, error)Goroutines and Context
When you spawn a goroutine, pass it the context:
For fan-out work, golang.org/x/sync/errgroup gives you cancellation and error propagation without hand-rolling channel bookkeeping:
go get golang.org/x/sync/errgroupfunc fetchAll(ctx context.Context, urls []string) ([]Result, error) { g, ctx := errgroup.WithContext(ctx)
results := make([]Result, len(urls))
for i, url := range urls { idx, u := i, url g.Go(func() error { res, err := fetch(ctx, u) if err != nil { return err } results[idx] = res return nil }) }
if err := g.Wait(); err != nil { return nil, err }
return results, nil}Key points:
- Derive the child context with
errgroup.WithContextso we can stop siblings on error - Pass
ctxtofetch()so it can respect cancellation g.Wait()waits for every goroutine and returns the first error
Common Mistakes
1. Storing Context in Structs
type Service struct { ctx context.Context // DON'T}Context is for function parameters, not struct fields. It makes the lifetime unclear.
2. Ignoring ctx.Err()
select {case <-ctx.Done(): return nil // what happened?}Better:
case <-ctx.Done(): return fmt.Errorf("fetch interrupted: %w", ctx.Err())3. Not Checking Cancellation in Tight Loops
for i := 0; i < 1e9; i++ { // heavy computation // never checks ctx.Done()}Check periodically:
for i := 0; i < 1e9; i++ { if i % 1000 == 0 { select { case <-ctx.Done(): return ctx.Err() default: } } // heavy computation}4. Creating Background Contexts Deep in the Stack
func helper() { ctx := context.Background() // wrong db.QueryContext(ctx, ...)}Always accept context from the caller:
func helper(ctx context.Context) { db.QueryContext(ctx, ...)}Values: Use Sparingly
Context values carry request-scoped metadata:
type contextKey string
const traceIDKey contextKey = "traceID"
ctx = context.WithValue(ctx, traceIDKey, traceID)Access them with a type check:
traceID, ok := ctx.Value(traceIDKey).(string)if !ok { return errors.New("missing trace ID")}Rules for values:
- Use for request-scoped data (trace IDs, auth tokens), not application data
- Use typed keys to avoid collisions:
type contextKey string - Don’t use as a replacement for function parameters
- Document what keys your function expects
Graceful Shutdown
The ultimate cancellation pattern: shutting down a server cleanly.
func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop()
srv := &http.Server{ Addr: ":8080", Handler: http.HandlerFunc(handler), }
go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() srv.Shutdown(shutdownCtx) }()
if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatal(err) }}signal.NotifyContext creates a context that cancels on Ctrl+C. We use that to trigger a graceful shutdown with its own timeout.
Summary
- Always derive from the caller’s context — never create
Background()deep in the stack - Pass context as the first parameter — make it obvious and consistent
- Check
ctx.Done()in loops and selects — give your goroutines an exit - Call
cancel()in defer — clean up resources, prevent leaks - Use
WithTimeoutfor external calls — protect against slow dependencies - Use values sparingly — they’re convenient but opaque
Context is about scope: every request gets a boundary, every goroutine lives within it, and when the boundary closes, everything inside knows to stop.
That’s how you build systems that don’t leak: memory, connections, or intentions.
Related Posts
Go Pipeline Pattern: Turning Streams into Useful Data
Learn the Pipeline Pattern in Go using goroutines and channels. Build composable stages for parsing, filtering, enriching, and processing log streams.
Mastering the Worker Pool Pattern in Go
Master the Worker Pool Pattern in Go to manage concurrent tasks efficiently. Control resource usage, improve throughput, and scale your applications.
Mastering the Generator Pattern in Go
Master the Generator Pattern in Go using goroutines and channels. Learn lazy evaluation, composability, and practical examples for data streams and iterators.