Using context.Context in Go: Cancellation, Timeouts, and Request-Scoped Values
Use Go context.Context correctly for cancellation, timeouts, goroutines, and request-scoped values, with practical patterns and examples.

The practical rule for Go context is straightforward: pass ctx context.Context as the first parameter to every operation that needs cancellation, deadlines, or request-scoped data. Derive a child context when you need a narrower lifetime, and always call the returned cancellation function.
func DoSomething(ctx context.Context, arg Arg) error {
// Use ctx while processing arg.
return nil
}
That convention lets cancellation travel through a service instead of stopping at an arbitrary function boundary.
What a Context actually provides
A Context represents the lifetime of an operation. It can report that the operation has been canceled and is no longer needed, allowing downstream functions and services to stop early.
The interface has four methods:
type Context interface {
Done() <-chan struct{}
Err() error
Deadline() (deadline time.Time, ok bool)
Value(key interface{}) interface{}
}
Each method answers a different question:
Donereturns a channel that is closed when work associated with the context should stop.Errreports why the context was canceled.Deadlinereports the deadline, if one exists.Valueretrieves request-scoped data associated with a key.
Code performing interruptible work commonly selects on ctx.Done() alongside its normal work. Once Done is closed, it should abandon the work and return. The official Go context article explains this signaling model in more detail.
Why Context has no Cancel method
Context deliberately exposes a receive-only Done channel and has no Cancel method. The code receiving a cancellation signal is usually not the code authorized to send it.
That separation matters in concurrent programs. If a parent operation starts several sub-operations, those sub-operations should be able to observe cancellation without gaining the ability to cancel their parent or siblings.
Cancellation authority instead comes from functions such as context.WithCancel:
func run(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
result := make(chan error, 1)
go func() {
result <- doWork(ctx)
}()
select {
case err := <-result:
return err
case <-ctx.Done():
return ctx.Err()
}
}
run owns cancel, while doWork only receives ctx. Calling cancel closes the derived context's Done channel. Cancellation of parent closes it as well, whichever happens first.
The deferred call is still necessary when doWork finishes normally: it releases resources associated with the derived context.
Derived contexts form a tree
WithCancel, WithDeadline, and WithTimeout each take a parent context and return a derived child plus a CancelFunc. context.Background() is the never-canceled root normally used when starting a context tree.
Cancellation flows down that tree. Canceling a context cancels every context derived from it:
parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)
defer cancelChild()
cancelParent()
// This receive completes because canceling parent also cancels child.
<-child.Done()
Cancellation does not flow upward. The holder of cancelChild cannot use it to cancel parent.
This tree structure lets one request context coordinate multiple layers of work. The same context may also be passed to several goroutines; contexts are safe for simultaneous use by multiple goroutines, so canceling that shared context can signal all of them.
Always call the CancelFunc
Calling a returned CancelFunc does more than notify running work. It cancels the child and its children, removes the parent's reference to the child, and stops associated timers.
Failing to call it leaks the child and its descendants until the parent is canceled. The standard pattern is therefore to defer cancellation immediately after checking that the context was created:
ctx, cancel := context.WithCancel(parent)
defer cancel()
return performOperation(ctx)
This is useful even when another cancellation path appears inevitable. Operations can finish early, and future edits can add returns that are easy to miss. go vet checks whether CancelFunc values are used on all control-flow paths. The context package documentation describes both the cleanup behavior and this check.
Adding a timeout to a slow operation
context.WithTimeout(parent, timeout) is equivalent to calling WithDeadline with time.Now().Add(timeout). It is convenient when the duration matters more than the absolute deadline.
func slowOperationWithTimeout(ctx context.Context) (Result, error) {
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel() // Releases resources if slowOperation finishes early.
return slowOperation(ctx)
}
The downstream operation receives the derived context, not the original one. It can stop when the timeout expires, when an ancestor is canceled, or when this function calls cancel.
Deferring cancel remains important even though the context has a timeout. If slowOperation returns before 100 milliseconds, the explicit cancellation releases the associated resources without waiting for the timeout.
The same pattern applies to database calls that accept a context. Go's database cancellation documentation demonstrates deriving a timeout and passing it to QueryContext.
Pass contexts; do not store them
A context should normally be scoped to one operation. Storing it in a long-lived struct ties unrelated calls to the context chosen when that struct was created:
// Avoid this design.
type BadWorker struct {
ctx context.Context
}
func (w *BadWorker) Process(job Job) error {
return process(w.ctx, job)
}
A caller of Process cannot provide a deadline or cancellation signal specifically for that call. Its lifetime becomes intermingled with the shared context stored in BadWorker.
Pass the context per call instead:
type Worker struct{}
func (w *Worker) Process(ctx context.Context, job Job) error {
return process(ctx, job)
}
Now different callers can use different deadlines, cancellation signals, and request-scoped values with the same Worker. The context is also visibly scoped to Process, with no suggestion that a later method call will reuse it. The Go article on contexts and structs discusses this design tradeoff and recommends the pass-as-argument form.
Values are for request-scoped data
Context.Value is intended for request-scoped data that crosses API and process boundaries. It is not a substitute for ordinary parameters or an options structure.
If a value controls how a function behaves—such as a page size, retry choice, or feature option—make it an explicit parameter. Context values are appropriate when data belongs to the request and must travel through layers that otherwise do not need it.
A short context checklist
Before shipping a context-aware API, check these rules:
- Accept
context.Contextas the first parameter, conventionally namedctx. - Propagate that context to downstream calls that accept one.
- Derive a child only when you need a narrower cancellation scope or deadline.
- Call every returned
CancelFunc, commonly withdefer cancel()immediately after derivation. - Remember that canceling a parent cancels all derived children.
- Do not store contexts in structs for ordinary per-operation use.
- Do not pass
nil; usecontext.TODO()when you are unsure which context to provide. - Reserve context values for request-scoped data, not optional function arguments.
- It is safe to pass the same context to multiple goroutines that should receive the same cancellation signal.





Comments
No comments yet.