What does context.Cause(ctx) tell you that ctx.Err() cannot?
answer
- two values for one event
- the coarse one must never change
- one sentinel cannot hold a reason
- falls back when nobody supplied one
basics
~10 sctx.Err() only ever reports context.Canceled or context.DeadlineExceeded. context.Cause returns the specific error a WithCancelCause cancel func was given, so you learn why the context was cancelled, not merely that it was.
solid answer
~30 s`ctx.Err()` is deliberately coarse: it reports `context.Canceled` or `context.DeadlineExceeded` and nothing else, so no matter what reason you supply, the `Err()` contract stays stable. `context.Cause(ctx)` is the escape hatch. If the context was cancelled through a `context.CancelCauseFunc` called as `cancel(err)`, `Cause` returns that `err`; otherwise it returns the same value as `ctx.Err()`. So a shutdown handler can cancel with `errors.New("sigterm received")` and a downstream goroutine can report *that*, while every existing `errors.Is(err, context.Canceled)` check keeps working. In practice you log or wrap `context.Cause(ctx)` and still classify on `ctx.Err()`. `Cause` returns nil while the context is still live.
code
go · 7 linesctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("upstream returned 503"))
fmt.Println(ctx.Err()) // context canceled
fmt.Println(context.Cause(ctx)) // upstream returned 503
fmt.Println(errors.Is(ctx.Err(), context.Canceled)) // truego deeper
Know that a cancelled context reports only context.Canceled, and that context.Cause exists to answer the separate question of why. You are not expected to have used it yet.
Explain the two-channel design: Err() stays a fixed two-value contract while Cause carries an arbitrary error, and Cause falls back to Err() when nobody supplied a reason.
Show where a cause earns its keep — naming which of several stacked deadlines fired, or which sibling failure tore the tree down — and why you log the cause but still branch on Err().
Decide what your services put in a cause and what it commits you to: a cause is read by other teams' logs and dashboards, so its wording becomes an informal contract you cannot casually change.
## The problem Cause solves `ctx.Err()` is a two-valued contract by design: `context.Canceled` or `context.DeadlineExceeded`, plus `nil` while the context is live. That stability is valuable — every caller in every package can classify on those two sentinels forever. It is also lossy. In a service with a cancellation tree of any size, `context.Canceled` tells you a decision was made somewhere above you, and nothing at all about *what* decision. A request handler that fans out to three backends and tears the rest down when one fails; a daemon whose contexts are all cancelled on SIGTERM; a pipeline stage cancelled because the consumer's buffer overflowed — all three look identical downstream. Every goroutine reports `context canceled` and the logs say nothing useful. ## What Cause adds `context.Cause(ctx) error` reads a second, richer value that travels alongside `Err()`: - If the context (or an ancestor) was cancelled by a `context.CancelCauseFunc` invoked as `cancel(err)` with a non-nil `err`, `Cause` returns exactly that `err`. - If it was cancelled some other way — an ordinary cancel func, or a deadline — `Cause` returns the same value as `ctx.Err()`, i.e. `context.Canceled` or `context.DeadlineExceeded`. - If the context is not finished, `Cause` returns `nil`. The key property, and the one interviews probe, is that **`Err()` does not change**. Cancelling with a cause still leaves `ctx.Err()` equal to `context.Canceled`. The cause is carried on a separate channel of information, so adding one cannot break a single existing `errors.Is(err, context.Canceled)` check anywhere in the program. That is a deliberate compatibility decision, not an accident. ```go ctx, cancel := context.WithCancelCause(context.Background()) cancel(errors.New("upstream returned 503")) fmt.Println(ctx.Err()) // context canceled fmt.Println(context.Cause(ctx)) // upstream returned 503 ``` As with the first cancellation of any context, the cause latches: the first cancellation in the chain sets it, and later calls with different errors do not overwrite it. Cancelling with a nil error is equivalent to an ordinary cancel, so `Cause` falls back to `context.Canceled`. ## How to use it in error returns The idiomatic pattern keeps both halves. Classify on the coarse value; report the rich one: ```go select { case <-ctx.Done(): // still returns Canceled or DeadlineExceeded to errors.Is return fmt.Errorf("render page: %w", context.Cause(ctx)) case r := <-out: return r, nil } ``` When no cause was set, `context.Cause` *is* `ctx.Err()`, so this is a strict improvement over returning `ctx.Err()` — you lose nothing and sometimes gain the real reason. When a cause was set, the returned chain carries a domain error the caller can match with `errors.Is` or pull out with `errors.As`, which is exactly what a coarse sentinel cannot give you. The symmetric mistake is to classify *on* the cause. A cause is arbitrary — any error the canceller felt like passing — so `errors.Is(context.Cause(ctx), context.Canceled)` is not a reliable "was this cancelled" test. Ask `ctx.Err()` for that. ## Deadlines have causes too The same idea extends to deadlines: `context.WithDeadlineCause` and `context.WithTimeoutCause` (Go 1.21) attach a cause that surfaces when the deadline fires. `ctx.Err()` still returns `context.DeadlineExceeded`; `context.Cause(ctx)` returns the supplied error. This is genuinely useful when several deadlines are stacked in one chain — an overall request budget, a per-attempt budget, a per-backend budget — and `context.DeadlineExceeded` alone cannot say which of them expired. Give each layer a named cause and the answer is in the error. ## Where it fits in an error taxonomy Think of it as two axes of the same event. `Err()` answers *what class of stop was this* — a decision, or time running out — and is safe to switch on because it can only ever be one of two values. `Cause` answers *who stopped it and why*, is open-ended, and belongs in logs, in span attributes, and in wrapped errors handed back to a caller who may want to inspect them. A reader arriving from a language where cancellation shows up as an exception will look for the reason inside the exception object. In Go the reason is not inside `context.Canceled` — that value is a single shared sentinel with a fixed message and no fields. The reason lives beside it, and `context.Cause` is how you read it.
- What does context.Cause return for a context cancelled by an ordinary cancel func, with no cause given?It returns the same value as `ctx.Err()` — `context.Canceled`. For an expired deadline with no cause attached it returns `context.DeadlineExceeded`. And while the context is still live it returns nil. That fallback is what makes `context.Cause(ctx)` a safe drop-in wherever you were returning `ctx.Err()`.
- Why does ctx.Err() still say canceled after cancel(err) supplied a specific error?Because `Err()` is a fixed two-value contract that the whole ecosystem classifies on. If a cause could change it, adding a cause in one package would break `errors.Is(err, context.Canceled)` checks everywhere else. The reason is carried on a separate channel — `context.Cause` — precisely so the coarse contract stays stable.
- Should you classify retryability by looking at context.Cause?No. A cause is an arbitrary error chosen by whoever cancelled, so it is not a dependable classifier. Use `ctx.Err()` to decide what class of stop occurred, and treat the cause as diagnostic detail to log, wrap or attach to a span. Reading the cause into a control-flow decision couples you to another layer's wording.
saying these in an interview costs you the question
- Claiming ctx.Err() returns the cause after cancel(err)
- Expecting Cause to be non-nil on a live context
- Switching control flow on the cause value
- Thinking a later cancel call overwrites the first cause
- Assuming a deadline can never carry a cause