Mastering Go's context Package: Cancellation, Deadlines, and Best Practices
Mastering Go's context Package: Cancellation, Deadlines, and Best Practices
Most Go developers run into context.Context within their first week. It's the first parameter of database calls and HTTP handlers, and of nearly every function in a well-built service. Many developers still treat it as boilerplate, though. They pass it along without really knowing what it does. That gap causes real bugs: goroutines that leak, requests that run long after the client has left, and timeouts that don't fire.
This article explains what the context package is for and how cancellation moves through a program. It also covers the patterns that make concurrent Go code reliable.
The Problem Context Solves
Picture an HTTP server handling a search request. The handler calls a database, two internal microservices, and a cache, and some of these calls run in parallel. Halfway through, the user closes the browser tab.
Without a coordination mechanism, all that work keeps going. The database query runs to completion and the microservices reply to nobody. Under load, this wasted work adds up and can bring a service down.
The context package gives you one standard way to carry three things across API boundaries and goroutines:
- Cancellation signals: "stop what you're doing."
- Deadlines: "stop at this point in time."
- Request-scoped values: small pieces of metadata such as trace IDs.
The Interface
context.Context is a small interface:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Done() is the most important method. It returns a channel that gets closed when the context is cancelled. Closing a channel acts as a broadcast: every goroutine waiting on it wakes up at once. That's why one cancellation can reach any number of workers.
Err() tells you why the context ended. It returns nil while the context is still active, context.Canceled if someone cancelled it explicitly, or context.DeadlineExceeded if time ran out.
Building a Context Tree
Contexts form a tree. You start from a root, usually context.Background(), and derive children from it. Each of these is a separate way to derive a child:
// Cancel manually:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Cancel after a duration:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
// Cancel at a fixed point in time:
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Minute))
defer cancel()
The key rule is that cancellation flows downward and never upward. When a parent is cancelled, all of its children are cancelled too. Cancelling a child has no effect on its parent or its siblings. So a handler can give one subtask a tight one-second timeout without risking the whole request.
Deadlines also respect the tree. If the parent expires in 500ms and you derive a child with a 2-second timeout, the child still ends at 500ms. A child can never outlive its parent.
Always Call cancel
Every With* function that returns a context and a cancel function (WithCancel, WithTimeout, WithDeadline, and their Cause variants) expects you to call that function, usually with defer:
func fetchUser(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return db.QueryUser(ctx, id)
}
Even when the operation finishes successfully before the timeout, calling cancel releases the resources tied to the context, including its timer and its link to the parent. If you skip it, those resources stay around until the parent is cancelled or the timer fires. With a long-lived parent and no timeout, that can mean a slow memory leak. go vet includes a lostcancel check that flags this mistake. Keep it enabled in CI.
Honoring Cancellation in Your Own Code
Requests built with http.NewRequestWithContext and the Context methods in database/sql (such as QueryContext) already respect contexts. When you write your own long-running loops or goroutines, you have to check for cancellation yourself. The standard tool is a select statement:
func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-jobs:
if !ok {
return nil
}
res := process(job)
select {
case results <- res:
case <-ctx.Done():
return ctx.Err()
}
}
}
}
The worker checks ctx.Done() twice: once while waiting for a job and once while sending the result. A goroutine stuck sending on a channel nobody reads is a classic leak. Any blocking operation should be paired with a check on ctx.Done().
For CPU-heavy loops with no channel operations, check the context now and then:
for i, item := range items {
if i%1000 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
compute(item)
}
Fan-Out with errgroup
A common pattern is to run several operations in parallel and cancel all of them as soon as one fails. The golang.org/x/sync/errgroup package does this:
func loadDashboard(ctx context.Context, userID string) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var profile *Profile
var orders []Order
g.Go(func() error {
var err error
profile, err = fetchProfile(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = fetchOrders(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &Dashboard{Profile: profile, Orders: orders}, nil
}
If fetchProfile fails, the derived ctx is cancelled. As long as fetchOrders honors its context, it gets the signal and returns early, and Wait returns the first error. You get structured concurrency without writing the cancellation logic yourself. errgroup also offers SetLimit to cap how many goroutines run at once, which helps when fanning out over large inputs.
Knowing Why: Cancellation Causes
For years, one frustration with context was that Err() only ever said "canceled" or "deadline exceeded." When something was cancelled deep in a call stack, nothing recorded the reason.
Go 1.20 added context.WithCancelCause, and Go 1.21 added WithDeadlineCause and WithTimeoutCause:
ctx, cancel := context.WithCancelCause(parent)
// Somewhere else:
cancel(fmt.Errorf("upstream inventory service unavailable"))
// Later:
fmt.Println(ctx.Err()) // context canceled
fmt.Println(context.Cause(ctx)) // upstream inventory service unavailable
ctx.Err() keeps its original behavior for backward compatibility, and context.Cause(ctx) gives you the detailed reason. In large systems this makes debugging much easier, because logs can say exactly why a request was abandoned.
Other Modern Additions
Go 1.21 also added two helpers worth knowing.
context.WithoutCancel returns a context that keeps the parent's values but isn't cancelled when the parent is. It's useful for work that must finish after a request ends, such as writing an audit log:
go writeAuditLog(context.WithoutCancel(ctx), event)
Use it carefully. Detached work should usually still get its own timeout.
context.AfterFunc registers a function to run in its own goroutine once a context is done, so you don't have to write and manage that goroutine yourself:
stop := context.AfterFunc(ctx, func() {
conn.Close()
})
defer stop()
This is a clean way to interrupt blocking operations that don't accept a context, such as some network or legacy I/O calls.
Context Values: Use Sparingly
context.WithValue lets you attach key-value pairs to a context. It's tempting to use it as a general-purpose bag for dependencies. Don't. The Go documentation says to use context values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions. Typical examples are trace IDs, authenticated user identity, and request IDs.
Two rules keep value usage safe:
type ctxKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(ctxKey{}).(string)
return id, ok
}
First, use an unexported key type so different packages can't collide. Second, wrap access in typed helper functions so callers never handle raw any values. Database handles, loggers, and configuration should be passed as explicit parameters or struct fields. Hiding them in a context makes dependencies invisible and code harder to test.
Common Mistakes to Avoid
- Storing contexts in structs. Pass
ctxas the first parameter of each function. A context stored in a struct tends to outlive the request it belongs to. The Go blog names one main exception: keeping an existing API backward compatible, which is whynet/http'sRequestcarries a context. - Passing
nilas a context. If you don't know which context to use yet, passcontext.TODO(). It's a searchable marker that the code still needs work. - Ignoring
ctxafter accepting it. A function that takes a context but never checks it or passes it on misleads its callers. - Using
context.Background()deep in a call stack. This cuts the cancellation chain. Code below that point can no longer be stopped by the original caller. - Treating timeouts as optional. Every outbound network call should have a deadline. A missing timeout can leave a request hanging forever.
Conclusion
The context package is small, but it carries the design approach behind Go's concurrency model: explicit control flow, cooperative cancellation, and clear ownership of work. Once you treat context as the lifeline of a request, the practices above follow naturally. Derive child contexts with timeouts, always call cancel, select on Done() whenever you block, and keep values minimal.
The newer APIs (WithCancelCause, WithoutCancel, and AfterFunc) fill gaps that used to require awkward workarounds, so it's worth checking older code for places where they would help. Get context right and your services will stop work promptly and clean up after themselves under load.