Go Context Cause: Stop Debugging Blind context canceled Errors
Go 1.20 introduced context.WithCancelCause and context.Cause, transforming cancellation from a generic signal into a traceable failure event.
The Real Problem With context canceled
ctx.Err() only tells you what class of failure happened:
context.Canceledcontext.DeadlineExceeded
That’s enough to branch logic, but useless when you’re trying to answer:
- Which dependency failed?
- Why did we cancel this request?
- Where did the timeout originate?
In production, this turns into a wall of identical context canceled logs with no way to distinguish user aborts, upstream failures, or internal guardrails.
Go 1.20+ fixes this by separating cancellation class from cancellation cause.
Two dimensions of failure
`ctx.Err()` = broad category (canceled vs deadline exceeded). `context.Cause(ctx)` = concrete reason (domain error, wrapped error, etc.).
What Go 1.20+ Changed
New APIs:
- Go 1.20
context.WithCancelCause(parent)→(ctx, cancel func(error))context.Cause(ctx)→error- Go 1.21
context.WithTimeoutCause(parent, d, cause)context.WithDeadlineCause(parent, t, cause)
This turns cancellation into a traceable event:
- You still use
ctx.Done()andctx.Err()for control flow. - You additionally use
context.Cause(ctx)to understand why the context ended.
Pattern 1: Use WithCancelCause For Explicit Failure Paths
At request or job boundaries, wrap work in a single WithCancelCause and set meaningful domain errors at the closest failure point.
Example: HTTP handler orchestration
Use one CancelCauseFunc and attach causes where you decide to abort:
var (
ErrUpstreamUnavailable = errors.New("upstream unavailable")
ErrBadRequest = errors.New("bad request")
)
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancelCause(r.Context())
defer cancel(nil) // nil on normal completion
// Validate input
if err := validate(r); err != nil {
cancel(fmt.Errorf("validate request: %w", ErrBadRequest))
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Call upstream service
resp, err := callUpstream(ctx, r)
if err != nil {
cancel(fmt.Errorf("call upstream: %w", ErrUpstreamUnavailable))
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
// Happy path: we finished without needing to set a cause
// defer cancel(nil) runs here
_ = resp
}
func logRequestEnd(ctx context.Context, logger *zap.Logger) {
errClass := ctx.Err() // context.Canceled or context.DeadlineExceeded or nil
cause := context.Cause(ctx) // domain error or nil
logger.Info("request done",
zap.Error(errClass),
zap.NamedError("cause", cause),
)
} One cancel function per orchestration scope
Create a single `WithCancelCause` at the orchestration boundary (handler, worker, job) and set causes at the highest level that understands *why* you’re aborting.
Pattern 2: Understand the WithTimeoutCause Trap
WithTimeoutCause looks like a drop-in replacement for WithTimeout, but it is not a general-purpose WithCancelCause with a timer.
Signature: