Go Context Cancellation: Ownership, Causes, and Detachment
Propagate Go context cancellation down call trees, release derived-context resources, preserve causes, and detach work safely.
In Go, cancellation flows from a parent context.Context to every derived child, not back from a child to its parent or sideways to siblings. The code that creates a cancellable child owns the returned cancel function and should call it on every path. Pass the context down the call tree; do not let lower layers invent unrelated roots.
This article targets Go 1.27. context.Cause and WithCancelCause require Go 1.20. WithoutCancel, WithDeadlineCause, and WithTimeoutCause require Go 1.21. Basic cancellation and deadlines are available in much older releases.
Derivation forms an ownership tree
The context package contract defines a Context as a carrier for deadlines, cancellation signals, and request-scoped values across API boundaries. Functions such as WithCancel, WithDeadline, WithTimeout, and WithValue return a child that points to a parent.
When a parent is canceled, its derived children are canceled. Canceling one child does not cancel the parent, so siblings continue unless the parent or their own branch is canceled. This direction lets a request stop all subordinate work without allowing one optional operation to terminate unrelated work.
Pass ctx as the first parameter of each operation that needs it:
func LoadProfile(ctx context.Context, id string) (Profile, error)
Do not pass a nil context. Use context.TODO() temporarily when the correct parent is not yet available. The package documentation also advises against storing contexts in structs: an explicit parameter makes the lifetime and propagation path visible to callers and static analysis.
The creator must release the child
Every function that creates a cancellable context receives a cleanup responsibility:
ctx, cancel := context.WithTimeout(parent, 250*time.Millisecond)
defer cancel()
Call cancel even when the operation completes before the timeout. Cancellation removes the parent’s reference to the child and stops associated timers. According to the package docs, omitting it retains those resources until the parent is canceled. go vet checks that cancel functions are used on all control-flow paths.
The rule is ownership, not “always defer” in isolation. A function may transfer the cancel function to a longer-lived owner, but that transfer should be explicit. In ordinary request code, placing defer cancel() immediately after construction is the least error-prone pattern.
Check cancellation at blocking boundaries
Passing a context has no magical effect on arbitrary code. An operation must observe Done, call another context-aware API, or otherwise arrange to stop. A worker loop can select between work and cancellation:
func run(ctx context.Context, jobs <-chan string) error {
for {
select {
case <-ctx.Done():
return context.Cause(ctx)
case job, ok := <-jobs:
if !ok {
return nil
}
process(job)
}
}
}
ctx.Err() reports the stable category context.Canceled or context.DeadlineExceeded. context.Cause(ctx) can preserve a more specific reason and falls back to the same category when no custom cause was recorded.
CPU-heavy work that never blocks may need periodic checks. Do not start a goroutine merely to wait for Done if the underlying operation cannot be interrupted; that adds another lifetime to manage without canceling the real work.
Causes retain why cancellation happened
Go 1.20 introduced cancellation causes. This program is runnable on Go 1.21 and later because it uses WithTimeoutCause:
package main
import (
"context"
"errors"
"fmt"
"time"
)
var ErrBudget = errors.New("processing budget exhausted")
func main() {
ctx, cancel := context.WithTimeoutCause(
context.Background(),
10*time.Millisecond,
ErrBudget,
)
defer cancel()
<-ctx.Done()
fmt.Println(ctx.Err())
fmt.Println(errors.Is(context.Cause(ctx), ErrBudget))
}
It prints context deadline exceeded and then true. The ordinary error remains useful for broad timeout handling; the cause carries application meaning.
With WithCancelCause, the returned function accepts an error. Passing nil still cancels the context and records context.Canceled. The first applicable cancellation wins for that branch. The documentation for context.Cause details the parent-child rule: if the parent is canceled first, parent and child share that cause; if the child is canceled first, it keeps its own cause while the parent can later acquire another.
Causes are still API. Use stable errors that callers may inspect with errors.Is, and do not attach sensitive internal data merely because the value can travel through the call tree.
Values are request data, not optional arguments
context.WithValue is for request-scoped data that crosses APIs, such as a trace identifier—not for configuration that belongs in normal parameters. Keys must be comparable, and the package documentation recommends a private defined type instead of string to avoid collisions:
type traceKey struct{}
ctx = context.WithValue(ctx, traceKey{}, traceID)
Values follow derivation even when cancellation does not. That distinction matters for detached work.
Detach cancellation, then add a new bound
Go 1.21’s context.WithoutCancel(parent) returns a child that can still read the parent’s values but does not inherit its cancellation or deadline. Its Done channel is nil, Err and Cause return nil, and Deadline reports none. The Go 1.21 release notes introduced this behavior.
Detachment is useful for bounded cleanup, audit delivery, or another operation that must outlive the request. Used alone, it creates work with no time limit. Add a new owner and bound immediately:
detached := context.WithoutCancel(requestCtx)
cleanupCtx, cancel := context.WithTimeout(detached, 2*time.Second)
defer cancel()
return flushAudit(cleanupCtx)
This keeps request-scoped values, ignores the client’s cancellation, and gives cleanup its own two-second lifetime. If the work belongs to a service rather than a request, a service-lifecycle context may be a clearer parent than WithoutCancel.
Timeouts do not guarantee prompt exit
A deadline closes Done; it does not kill a goroutine. Prompt exit depends on every layer honoring cancellation. Network and database APIs need the context-aware method, blocking sends need a cancellation case, and retry loops need to check before sleeping or starting another attempt.
Cancellation also does not replace result coordination. If a caller must know that workers have stopped, wait for them with a channel, an error group, or another ownership mechanism after canceling.
The dependable pattern is a tree with clear owners: receive a parent, derive the narrowest needed child, defer its cancel function, propagate it to subordinate calls, and wait for owned goroutines. Use causes for stable diagnostic meaning and detach only when the new work receives a separate lifetime.