A Go context records its cancellation cause when cancellation first reaches that context. Later cancellation attempts do not replace the recorded cause. This makes context.Cause a record of the winning cancellation event rather than a mutable error slot.
The distinction matters in context trees because parent and child cancellation can race. The first event to cancel a given node fixes that node’s cause, while another node in the same tree can retain a different cause.
Err and Cause carry different information
Context.Err reports one of the standard cancellation states: context.Canceled or context.DeadlineExceeded. context.Cause can retain a more specific error when cancellation comes from a CancelCauseFunc.
ctx, cancel := context.WithCancelCause(context.Background())
errShutdown := errors.New("service shutdown")
cancel(errShutdown)
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))After the call to cancel, ctx.Err() is context.Canceled, while context.Cause(ctx) is errShutdown.
That split preserves the stable cancellation contract exposed by Err while allowing an application-specific reason to travel through the context tree. Code that only needs to detect cancellation can continue to inspect Err or Done; code that records or classifies the initiating condition can inspect Cause.
Passing nil to a CancelCauseFunc does not leave the cause unset. It cancels the context and gives it the standard context.Canceled cause.
The first cancellation fixes the cause
A cancellation cause is not replaced by a later call:
ctx, cancel := context.WithCancelCause(context.Background())
errA := errors.New("upstream connection closed")
errB := errors.New("local shutdown")
cancel(errA)
cancel(errB)
fmt.Println(context.Cause(ctx))The cause remains errA. Once the first call cancels ctx, later calls to the same cancellation function have no effect on the context state or its cause.
This property also applies when cancellation arrives from a parent. A child that has already inherited parent cancellation cannot later install a separate local cause. Conversely, a child canceled locally before its parent can retain its local cause even after the parent is canceled.
Parent-first cancellation propagates one cause downward
Consider a parent and a child, both created with cause-aware cancellation:
parent, cancelParent := context.WithCancelCause(context.Background())
child, cancelChild := context.WithCancelCause(parent)
errParent := errors.New("request aborted")
errChild := errors.New("worker stopped")
cancelParent(errParent)
cancelChild(errChild)Parent cancellation reaches the child before the child’s local cancellation call. The parent records errParent, and the child is canceled as part of that propagation. Both calls to context.Cause therefore return errParent.
The later cancelChild(errChild) call cannot revise the child’s already-fixed cause. Propagation has already crossed the child’s cancellation boundary.
This behavior keeps a parent-originated failure coherent across descendants that are still active when the parent is canceled. Each affected descendant observes the cause attached to the event that actually canceled it.
Child-first cancellation can split causes
Reversing the order changes the result:
parent, cancelParent := context.WithCancelCause(context.Background())
child, cancelChild := context.WithCancelCause(parent)
errParent := errors.New("request aborted")
errChild := errors.New("worker rejected input")
cancelChild(errChild)
cancelParent(errParent)The child records errChild first. Later, the parent records errParent, but that propagation cannot overwrite the child’s existing cause.
At that point:
context.Cause(parent) == errParent
context.Cause(child) == errChildA context tree therefore does not imply one globally shared cause. Cause propagation follows cancellation timing and ancestry. A descendant can form an earlier local cancellation boundary and preserve a cause that differs from the parent eventually canceled above it.
Deadline causes use the same fixed boundary
context.WithDeadlineCause and context.WithTimeoutCause attach a specific cause to deadline expiration. Their returned cancellation function is a regular CancelFunc, not a CancelCauseFunc.
If the deadline expires first, the configured cause becomes visible through context.Cause. If the returned cancellation function is called first, the context is canceled before deadline expiration and the configured deadline cause is not installed.
This makes the configured error conditional on the deadline being the event that wins cancellation. It is not metadata that is guaranteed to appear whenever the context eventually becomes done.
Cause is cancellation provenance, not mutable status
Cancellation cause is most precise when treated as provenance for the event that closed a context’s cancellation boundary. It can distinguish a local shutdown, a parent failure, and a deadline-specific condition without changing the basic Err contract.
The fixed-first-event rule also limits what cause can represent. It cannot accumulate several failures, rank competing errors after cancellation, or be revised when later cleanup exposes a more detailed error. Those jobs require separate error aggregation or operation state.
For concurrent systems, the resulting boundary is deterministic in meaning even when event ordering is not predetermined: whichever cancellation reaches a context first fixes that context’s cause, and later cancellation attempts leave it unchanged.