skip to content

What do a context.Context's Done, Err and Deadline methods tell the function that received it?

level: middleimportance: must knowfreq 62%

answer

  1. three questions a callee is allowed to ask
  2. should I stop, why, and how long
  3. the signal is a close, not a send
  4. one of them returns two values
  5. nil until the channel closes, then permanent

basics

~20 s

Done returns a channel closed when the work should stop; Err is nil until then and afterwards says why; Deadline returns when the work must finish plus a boolean saying whether one was set at all.

solid answer

~50 s

They are three questions a callee can ask about the caller's intent. `Done() <-chan struct{}` answers "should I stop?" — the channel is closed rather than written to, so any number of goroutines can wait on it and all unblock at once; it is what you put in a `select`. `Err() error` answers "why?" — nil while the context is live, non-nil once `Done` has closed, which makes it the value you return when you bail out. `Deadline() (time.Time, bool)` answers "how long do I have?" — the boolean is false when no deadline was set, and the time lets you refuse a five-second call with 200ms left. One subtlety: `Done` may return a nil channel for a context that can never be cancelled, and a receive on a nil channel blocks forever, so that `select` case simply never fires.

code

go · 11 lines
go
func (c *Client) Fetch(ctx context.Context, url string) error {
	if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 300*time.Millisecond {
		return fmt.Errorf("not enough budget: %w", context.DeadlineExceeded)
	}
	select {
	case <-ctx.Done():
		return ctx.Err() // context.Canceled or context.DeadlineExceeded
	case err := <-c.start(url):
		return err
	}
}

go deeper

for a junior

Recall the shape: Done gives a channel you select on, Err tells you why it stopped, Deadline tells you when time runs out. Know that returning ctx.Err() is the normal way to bail out.

for a middle

Explain why closing a zero-byte channel is the right broadcast primitive here, that Err is nil until Done closes and then permanent, and that Deadline's second return value must be checked before its first is used.

for a senior

Show judgment about where the check belongs: a select for anything that blocks, a periodic poll in a CPU-bound loop, and nothing at all when you simply forward the context. Be ready to explain why an operation can return its own error rather than the context's after cancellation.

for a principal

Frame this as an interface contract other teams depend on: what your libraries promise about responsiveness to cancellation, how quickly a call is expected to return once its context is done, and whether that promise is tested rather than assumed.

## The interface is the contract When a function accepts `ctx context.Context`, these four methods are the entire surface it gets. There is no `Cancel` here, and that is deliberate: the ability to cancel belongs to whoever *created* the context, and it is handed back separately as a function value. A callee can observe, but not cancel, the budget it was given. ```go type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any } ``` ## Done: the stop signal `Done()` returns a **receive-only channel of empty structs**. Two design choices in that one signature carry the whole mechanism. First, the element type is `struct{}`, which occupies zero bytes: nothing is ever *sent* on this channel. The signal is the **close**. Closing a channel makes every current and future receive on it complete immediately with the zero value, which means one close broadcasts to an unlimited number of waiters with no bookkeeping and no risk of one goroutine stealing another's notification. A send would reach exactly one receiver; a close reaches all of them. Second, it is receive-only, so a callee cannot close it and fake a cancellation upward. That makes the canonical usage a `select`: ```go select { case <-ctx.Done(): return ctx.Err() case res := <-work: return handle(res) } ``` **The nil-channel subtlety.** The documentation says `Done` may return `nil` if the context can never be cancelled, and an empty root is exactly such a context. A receive on a nil channel blocks forever. So a `select` case reading `<-ctx.Done()` on a never-cancellable context is simply never ready — the other cases behave as though the cancellation case were not written at all. No special-casing is required in your code, which is the point. Where this does bite is if you write `<-ctx.Done()` as a *bare* statement rather than a `select` case: with a never-cancellable context that deadlocks that goroutine permanently. ## Err: why it stopped, and what you return `Err()` is defined tightly: it returns `nil` as long as the `Done` channel is not yet closed, and a non-nil error afterwards. Once it returns non-nil, it keeps returning the same error forever — it never flips back. The two errors it yields are `context.Canceled`, when somebody explicitly gave up, and `context.DeadlineExceeded`, when a deadline passed. Both are sentinel values you compare with `errors.Is`. This is why the idiomatic bail-out is `return ctx.Err()` rather than a fabricated error of your own: the caller upstream can then distinguish "I cancelled this myself" from "we ran out of time", and can tell both apart from a genuine failure of the underlying work. An important consequence for error handling: after a cancellation, an in-flight operation may return *its own* error — a socket-closed error, a driver-specific one — rather than the context's. That is why code that cares often checks `ctx.Err() != nil` before deciding whether an error is worth logging as a real fault. ## Deadline: the budget, checked before you spend it `Deadline()` returns two values, and the boolean is the one people forget. It is `false` when no deadline was ever set anywhere in the chain, in which case the returned `time.Time` is the zero value and means nothing. When it is `true`, the time is the instant at which this context will be cancelled if it has not been already. This lets a callee make an admission decision rather than starting doomed work: ```go if dl, ok := ctx.Deadline(); ok && time.Until(dl) < minWork { return ErrNotEnoughTime } ``` A client library that knows a round trip takes at least 300ms can refuse immediately when 50ms remain, instead of opening a connection, sending a request and having it torn down. Because the deadline in the chain is the earliest one set anywhere above, what a callee reads here is the *real* budget, not just the one its immediate caller intended. ## Value: the fourth method `Value(key any) any` looks up request-scoped data attached higher in the chain, walking parents until it finds the key or runs out. It rounds out the interface: the first three describe *when to stop*, and this one describes *which request this is*. ## Putting it together A well-behaved callee typically does one of three things with these methods, in increasing order of effort: pass `ctx` on to something that already honours it and do nothing else; add a `select` on `ctx.Done()` around its own blocking wait and `return ctx.Err()`; or, in a tight CPU-bound loop with nothing to select on, poll `ctx.Err() != nil` every N iterations. What it must never do is receive a context and ignore it, because from the caller's point of view that call is now uninterruptible no matter what budget was set.

  • Why does the channel returned by ctx.Done() carry struct{} and get closed rather than written to?
    `struct{}` is zero bytes, because no value is ever transmitted — the *close* is the signal. A send would wake exactly one receiver, so the first goroutine to notice would consume the cancellation for everybody else. Closing unblocks every current and future receive at once, which is precisely what a broadcast to an unknown number of watchers needs.
  • What does ctx.Deadline() return for a context with no deadline set anywhere in its chain?
    The zero `time.Time` and `false`. The boolean is the meaningful half: ignore it and you will read the zero time as an instant in the year 1, conclude the budget expired long ago, and refuse every request. Always branch on `ok` before touching the time.
  • Can a function that receives a context.Context cancel it?
    Not through the interface — there is no `Cancel` method, by design. Cancellation authority stays with whoever created the context, in the separate `CancelFunc` value returned alongside it. A callee that wants to bound its own sub-work derives a child context and cancels that, which affects its own subtree and nothing above it.
  • A CPU-bound loop has nothing to block on. How does it honour its context.Context?
    Poll rather than select: check `ctx.Err() != nil` (or a non-blocking `select` on `ctx.Done()` with a `default`) every few thousand iterations and return `ctx.Err()` when it trips. Checking every iteration usually costs more than it saves; checking never makes the loop uninterruptible, which is the failure mode that actually hurts.

saying these in an interview costs you the question

  • Expects to receive a value from ctx.Done rather than a close
  • Ignores the boolean returned by ctx.Deadline
  • Thinks ctx.Err returns an error before cancellation happens
  • Looks for a Cancel method on the context.Context interface
  • Assumes ctx.Done never returns a nil channel