skip to content

What does ctx.Err() return after a context is cancelled versus after its deadline passes?

level: juniorimportance: must knowfreq 72%

answer

  1. three answers, never more
  2. one means time, one means intent
  3. the channel closes, the value latches
  4. wrapping breaks == but not errors.Is

basics

~10 s

ctx.Err() returns nil while the context is still live, context.Canceled once a cancel function has been called, and context.DeadlineExceeded once the context's deadline has passed. Match them with errors.Is, not with ==.

solid answer

~40 s

A `context.Context` has exactly three answers for `Err()`: `nil` while it is still active, `context.Canceled` if somebody called its cancel function, and `context.DeadlineExceeded` if its deadline expired first. Both are plain sentinel values, and `Done()` closing is the signal that one of them is now non-nil. The distinction is the whole point: `context.Canceled` means someone deliberately gave up (the caller disconnected, a sibling task failed, the user hit stop), while `context.DeadlineExceeded` means time ran out on its own. Always test with `errors.Is(err, context.Canceled)` rather than `err == context.Canceled`, because by the time the error reaches you it has usually been wrapped by a transport or by your own `fmt.Errorf("...: %w", err)`.

code

go · 10 lines
go
func fetch(ctx context.Context, in <-chan Result) (Result, error) {
	select {
	case r := <-in:
		return r, nil
	case <-ctx.Done():
		// context.Canceled if someone gave up,
		// context.DeadlineExceeded if time ran out.
		return Result{}, ctx.Err()
	}
}

go deeper

for a junior

Be ready to name all three return values of ctx.Err() — nil, context.Canceled, context.DeadlineExceeded — and to say plainly that one means someone gave up and the other means time ran out.

for a middle

Explain why errors.Is is required rather than ==, that Err() latches at the first cause and never changes, and that Done() closing is the same instant Err() becomes non-nil.

for a senior

Show the operational split: cancellations are usually normal traffic, deadline expiries are a health signal. Be ready to say what your service does differently with each and why returning ctx.Err() unaltered matters to callers.

for a principal

Own the convention across services: which sentinel is recorded on a span, what the error-rate SLI counts, and how you stop teams inventing bespoke timeout errors that break every caller's classification.

## The three states of ctx.Err() `context.Context` has four methods, and `Err() error` is the one that reports *why* the context finished. It answers one of three things: - **`nil`** — the context is still live. Nothing has cancelled it and no deadline has passed. - **`context.Canceled`** — someone called the cancel function associated with this context (or with one of its ancestors). - **`context.DeadlineExceeded`** — the context had a deadline and that moment arrived before anyone cancelled it. Those last two are package-level sentinel values: ```go var Canceled = errors.New("context canceled") var DeadlineExceeded error = deadlineExceededError{} ``` Once `Err()` is non-nil it never changes again — the first cause wins and is latched. `Done()` returns a channel that is closed at the same instant `Err()` becomes non-nil, which is why the standard shape of a cancellable operation is a `select` on `ctx.Done()` that then returns `ctx.Err()`. ## Why the two values are different, not interchangeable The two sentinels answer different operational questions, and this is why the standard library bothers to distinguish them. `context.Canceled` means *a decision was made*. Something upstream stopped wanting the result: the caller hung up, a parent task failed and tore down its children, a user navigated away. Nobody is waiting for your answer, so producing one is wasted work. It is almost never worth retrying, and it is usually not an alert-worthy error — logging every client disconnect as a failure is how a service ends up with a permanently red error rate. `context.DeadlineExceeded` means *time ran out*. The work was still wanted; it simply took longer than the budget allowed. That points at a slow dependency, a saturated pool, or a budget that is too tight — a genuine signal about the health of the system. ## Always use errors.Is Both values are sentinels, so a direct comparison works only when the error reaches you completely unwrapped. In practice it does not: transports and libraries wrap the cause, and your own code adds context with `%w`. The correct test is: ```go if errors.Is(err, context.DeadlineExceeded) { ... } if errors.Is(err, context.Canceled) { ... } ``` `errors.Is` walks the `Unwrap` chain, so it still matches when the sentinel is buried three layers down. `err == context.Canceled` silently returns false in exactly the situation you wrote the check for. This is the single most common bug on this topic. The symmetric mistake is *returning* something other than the sentinel. When your function gives up because the context finished, return `ctx.Err()` (optionally wrapped with `%w`) rather than inventing a `errors.New("timed out")`. Callers are matching on the sentinels; a hand-rolled string breaks every one of those checks. ## The dual identity of context.DeadlineExceeded `context.DeadlineExceeded` is not a bare `errors.New` value. Its concrete type implements `Timeout() bool` and `Temporary() bool`, both returning true, which means it satisfies the `net.Error` interface. Code that classifies network failures by asking `if ne, ok := err.(net.Error); ok && ne.Timeout()` will therefore also catch an expired context — deliberately, so that a context deadline looks like a timeout to generic network-error handling. `context.Canceled` has no such method: it is an ordinary `errors.New` value and `Timeout()` is not defined on it. So a cancelled context is *not* a timeout, by construction. ## For the engineer arriving from another language If your background is Java, Python or C#, a deadline probably arrives as a thrown exception — `TimeoutException`, `asyncio.TimeoutError`, `OperationCanceledException` — that unwinds the stack from wherever the blocking call was made. Go does none of that. Nothing is thrown, no goroutine is interrupted, and no stack is unwound. A context that finishes only closes a channel and sets `Err()`. Work stops only because your code is watching `ctx.Done()` and chooses to return, and the error only reaches the caller because you returned it. That is why a Go function that ignores its `context.Context` keeps running happily long after its caller has given up. The context is a broadcast that the answer is no longer wanted — not a mechanism that stops anything. ```go func fetch(ctx context.Context) (Result, error) { select { case r := <-resultCh: return r, nil case <-ctx.Done(): return Result{}, ctx.Err() // Canceled or DeadlineExceeded } } ``` That `return ctx.Err()` is the entire contract: the caller learns both that the work stopped and which of the two reasons stopped it.

  • Which of the two should page someone, and which should be logged quietly?
    `context.DeadlineExceeded` is the interesting one: the work was still wanted and the budget was blown, which points at a slow dependency or a too-tight deadline. `context.Canceled` usually means the caller walked away — a client disconnect, a cancelled request — and is normal traffic. Alerting on cancellations produces a permanently red error rate that nobody can act on.
  • If your function stops early because the context finished, what error should it return?
    Return `ctx.Err()`, or wrap it with `fmt.Errorf("...: %w", ctx.Err())`. Callers classify failures with `errors.Is(err, context.Canceled)` and `errors.Is(err, context.DeadlineExceeded)`; a hand-rolled `errors.New("timed out")` matches neither and quietly breaks every one of those checks.
  • Is context.DeadlineExceeded a net.Error?
    Yes. Its concrete type implements `Timeout()` and `Temporary()`, both returning true, so it satisfies `net.Error` and generic timeout classification catches it. `context.Canceled` is a plain `errors.New` value with no such methods, so a cancelled context is never reported as a timeout.

Canceled is the diner walking out; DeadlineExceeded is the kitchen missing the service window. Both stop the order, but only one is the kitchen's problem.

saying these in an interview costs you the question

  • Comparing with err == context.Canceled instead of errors.Is
  • Saying ctx.Err() returns the same value in both cases
  • Believing cancellation interrupts a running goroutine
  • Returning a custom errors.New instead of ctx.Err()
  • Treating client disconnects as service errors worth alerting on