Context in Go: cancellation only downwards, a cancel that is not about cancelling, and a Value that grows dearer with depth
The interview climbs a ladder: what a context is for — why it is the first argument — what cancel does and why it is always called — how Canceled differs from DeadlineExceeded — what belongs in Value. Measured: a forgotten cancel leaves 123 bytes per child forever, and Value at twenty layers is 18 times dearer.
Full technical treatment
TL;DR
A context solves one problem: work that nobody wants any more does not know it. The client has gone or the deadline has passed, while the database query still runs and still holds resources. A context carries three things down the call tree — cancellation, a deadline, and values tied to the request — so that this news reaches the very bottom of the chain. It interrupts nothing by itself: cancellation is a signal, and it works only where it is read.
Hence what surprises people. The link in the tree is one-way: cancelling
a parent reaches every child, cancelling a child touches neither the parent nor
the sibling — verified by running it. cancel is not about cancelling a result
but about releasing: it is always called, success included, or the child stays
in the parent's list. Measured: ten thousand children without cancel leave
123 bytes each, with cancel — 0. And Canceled and DeadlineExceeded
are different errors: after a timeout errors.Is(err, context.Canceled)
gives false, and code that checks only Canceled cannot tell a departed
client from its own missed deadline.
Beyond that come the numbers, the machinery and the boundaries. A context is
passed as the first argument rather than stored in a struct: every call has its
own deadline. Value carries request-scoped data that crosses package
boundaries — tracing, deadlines, authentication, but not function parameters.
Writing is cheap (one allocation) and reading is dear: the lookup walks up the
chain of parents, and at twenty layers that is 18 times dearer than at zero,
against 2.21 ns for a struct field. The ratio holds for this run on go1.24.7;
the rule that comes out of it is to read once at the entrance, not to remember
the number. And context.Background() is never cancelled, its Done()
being nil.
- a server takes a request, does some work on it and answers;
- the work for one request may run in several goroutines at once and reach out to other services;
- a client does not wait forever: it can walk away, and our own waiting time can run out.
WithCancel,WithTimeout,WithValue,Done(),Err()— all explained on the way;- how
Canceleddiffers fromDeadlineExceeded, whatWithoutCancelandAfterFuncare, and the nanoseconds from the measurements.
What is really being asked
The ladder is almost always this one:
- "What is a context for?" — the warm-up: cancellation, deadlines, request-scoped values.
- "Why the first argument rather than a struct field?" — the substance starts here.
- "What does
canceldo and why isdefer cancel()written even on success?" - "How does
Canceleddiffer fromDeadlineExceeded?" — the question timeout handling breaks on. - "What goes into
Value?" — and why "whatever is convenient" is wrong. - "How do you cancel an operation that takes no context?" — testing whether you know a context interrupts nothing by itself.
The lesson climbs that ladder. Its spine is one sentence: a context is a tree of signals, not a store and not an interrupt mechanism.
Base: why the work needs to know the request is no longer wanted
Start with the problem rather than with the package.
A client sent a request. The server took it and set to work: went to the database, called a neighbouring service, computed something. Meanwhile the client closed the tab and walked away. Nobody wants the answer any more — but the work on it carries on: the database query is still running, the connection is still busy, the memory is still held. Same story if the answer is simply too late: the client gave up on its own waiting time while we are still computing.
Work that nobody is waiting for is not merely extra work. Under load it crowds out the work that matters: every abandoned task occupies room that a live request would have had.
So the news — this is no longer wanted — has to reach the very bottom of the call chain somehow. No function down there can work it out on its own: it sees neither the client nor the clock. The news has to be carried, and it is carried the same way the request came: from the top down through the calls. That is what a context is.
It carries three things:
- cancellation — the news that the work is no longer wanted;
- a deadline — the moment after which it is not wanted in any case;
- values tied to the request — whatever belongs to the request as a whole and must be visible all the way along.
The first two are the same piece of news arriving for different reasons: someone cancelled, or the time ran out. The third is built differently and is taken separately.
And the main point: the news stops nothing by itself. It is available to be read — and it is the work that has to look at it. The minimal example goes like this: the work waits for two events at once, "the context is finished" and "the result is ready", and whichever comes first decides the outcome.
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return nil, ctx.Err()
}A function written that way is cancellable. A function that just computes in a loop and looks at nothing is not, however many contexts you hand it.
That is already enough to answer the basic interview question. What follows is about why the news travels only downwards and never up, why the cancel function is called even after success, how "cancelled" differs from "time ran out", and what the third thing — the values — costs.
Mechanism 1: four purposes that get conflated
context does four different things, and half the confusion in this topic
comes from treating them as one:
| purpose | set by | who uses it |
|---|---|---|
| cancellation — say the work is no longer wanted | WithCancel | whoever reads Done() |
| a deadline — bound the time from above | WithTimeout, WithDeadline | the same |
| propagation — carry both through every call | the first ctx argument | every function along the way |
| request-scoped values | WithValue | whoever needs the trace id |
The first three work together and are covered by the mechanisms below. The
fourth is built differently and stands apart, in the "Deeper" section;
conflating them is a mistake, because Value has nothing to do with either
cancellation or deadlines.
And the frame everything else is derived from: a context is a tree of signals. Not a store, and not a means of interruption.
A tree, and the link is one-way
Three contexts: a root and two children. Press "cancel A" and see what does not change:
A run of bench/gocontext/internals.go prints the same:
cancelled A: A=context canceled B=nil root=nil
cancelled the root: A=context canceled B=context canceled root=context canceled
The first line is the whole point. Cancelling a child touches neither the parent nor the sibling. The second is the other direction: cancelling the parent reaches everyone.
Two practical conclusions follow at once:
- A handler can cancel its own subtask without bringing down the whole
request:
ctx, cancel := context.WithCancel(parent), cancel — and the parent never noticed. - You can only cancel somebody's work if you hold its context. Which is why it is passed explicitly.
Do not store Contexts inside a struct type; instead, pass a Context explicitly
to each function that needs it. The Context should be the first parameter,
typically named ctx.
The reason is not style. A context is per call: one request has a five-second deadline, another a one-second one, a third is already cancelled. A struct field holds one context for every call, and the first concurrent request gets somebody else's deadline.
Mechanism 2: cancel is about releasing, not about cancelling
The most common question of understanding: why defer cancel() if the operation
finished successfully anyway?
The documentation answers outright:
Failing to call the CancelFunc leaks the child and its children until the parent
is canceled or the timer fires.
The word "leaks" should be taken literally, and a measurement shows of what. Ten thousand children of a long-lived parent:
| left on the heap per child | |
|---|---|
without cancel | 123 bytes |
with cancel | 0 |
And it is not about goroutines. The first draft of the measurement counted
runtime.NumGoroutine() and got zero added goroutines in both cases:
WithTimeout starts a runtime timer, not a goroutine. The claim "a
forgotten cancel leaks goroutines" was not supported by the measurement.
What leaks is something else: until cancel is called, the child stays in the
parent's list of children, and neither it nor anything it references can be
collected. In real code the parent is long-lived — a service or connection
context — so the list grows with the number of forgotten cancels.
Hence the formulation people want to hear: cancel does not "cancel a result"
but strikes the child off the list. That is why it is safe after success and
why it is written with defer right after the context is created.
go vet catches it: lostcancel — "the cancellation function returned by
context.WithCancel, WithTimeout, and WithDeadline must be called or the new
context will remain live until its parent context is cancelled".
Mechanism 3: a deadline is cancellation too, for a different reason
WithTimeout(ctx, d) is exactly WithDeadline(ctx, time.Now().Add(d)), and a
deadline is a cancellation scheduled in advance. A timeline shows what is shared
and what is not:
The left and the right do the same thing — they close Done(). Only Err()
differs, and it is on that difference that timeout handling breaks.
One more property of the timeline that gets asked about: a child's deadline
cannot be later than its parent's. The runtime takes the smaller of the two. So
a chain of WithTimeout at every level does not extend the deadline, it only
shortens it — which is exactly the behaviour propagation is wanted for.
Canceled and DeadlineExceeded are different errors
The question looks formal, and timeout handling breaks on it.
If Done is closed, Err returns a non-nil error explaining why: DeadlineExceeded
if the context's deadline passed, or Canceled if the context was canceled for
some other reason.
The run confirms it: after a timeout errors.Is(err, context.DeadlineExceeded)
gives true while errors.Is(err, context.Canceled) gives false.
Why this matters in practice. Two situations call for different behaviour:
- The client walked away (
Canceled) — there is no point working on, nobody to answer, and no reason to log it as an error. - We missed the deadline (
DeadlineExceeded) — that is a symptom: either a dependency is slow or the deadline is too tight. This goes into metrics and alerts.
Code that checks only Canceled lumps both into one — and the graphs lose
exactly what deadlines are set for.
Separately about Background and TODO. Both are empty contexts, and their
Done() is nil (a read from a nil channel blocks forever, so such a context
never "fires"). The difference between them is purely a signal to the reader:
Background is the root in main and in tests, TODO is a marker for "a real
context goes here later". Static analysis tools can look for TODO.
Mechanism 4: a context interrupts nothing
The most common misconception in this topic, and one worth being able to state.
Cancelling a context is a signal, not an interrupt. ctx.Done() closes, and
that is all. A goroutine that does not read it carries on as if nothing
happened. There is no "kill the operation" mechanism in the language — just as
there is no way to kill a goroutine.
Hence the rule: a context works only where it is read. Three places where that happens:
// 1. An explicit check in a loop.
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
process(item)
}
// 2. Waiting with cancellation.
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return nil, ctx.Err()
}
// 3. A library that takes a context itself.
rows, err := db.QueryContext(ctx, query)
resp, err := client.Do(req.WithContext(ctx))The third is the most important in practice: if a library takes no context, there is nothing to cancel its call with. A wrapper of the form "run it in a goroutine and return on a timeout" cancels only the wait, while the operation carries on and holds its resources. That is a legitimate technique, but it may not be called cancellation — and interviews check that distinction.
Mechanism 5: what it looks like along a whole request
Everything above is worth seeing once as a whole — along the path a real request takes:
Three scenarios on that path, and each gets asked about.
The client went away. http.Server closes the request context's Done();
the signal travels down to QueryContext, which aborts the query in the
database. The error at the handler is Canceled. There is nobody to answer, and
it should not be logged as a failure.
Two seconds were not enough. The handler's timer fired, Done() is closed,
Err() is DeadlineExceeded. That is a symptom: either the database is slow or
the deadline is too tight. This goes into metrics.
The handler finished early. defer cancel() removes the child from the
parent's list. Nothing is "cancelled" — space is released, which is exactly why
the call is mandatory on success too.
And the thing visible only in the whole picture: nobody "applies" a context —
it is passed. The work is aborted not by it but by QueryContext, which reads
it. Remove one link in that chain that passes ctx on, and everything below
stops being cancellable while the code still looks right.
Deeper: Value is cheap to write and dear to read
The rule in the documentation is stricter than it is usually retold:
Use context Values only for request-scoped data that transits processes and
APIs, not for passing optional parameters to functions.
Two conditions at once: request-scoped data and crossing boundaries. A trace id, authentication data, a tenant id — yes. A function's settings, dependencies, configuration — no: those are parameters and must be visible in the signature.
And the other half, about cost. Writing is cheap — one allocation. Reading
is dearer, and the cost grows with depth, because Value walks up the chain
of parents:
| layers above the value | Value | |
|---|---|---|
| 0 | 6.13 ns | ×1.0 |
| 5 | 30.30 | ×4.9 |
| 10 | 51.80 | ×8.4 |
| 20 | 110.58 | ×18.0 |
| a struct field | 2.21 | for scale |
The last row is a measure of how much this is not a substitute for a field. The
practical point: values are pulled out of a context once, at the entrance to a
handler, and passed explicitly from there. Reading ctx.Value in a hot loop
means paying eighteen times where one read would have done.
And here it is worth resisting the opposite overcorrection. Eighteen is not a
property of Value but a property of twenty layers — which still have to be
accumulated above the value first. The rule is written from the cause, not from
the ratio: since the lookup walks up the chain, read time depends on depth — so
you read at the entrance, where the depth is known and small, rather than in a
loop, where it is whatever it happens to be.
And one more thing, about type safety: the key must be your own unexported type, not a string.
type ctxKey string
const userKey ctxKey = "user"With a string key two independent packages may accidentally take the same name and silently overwrite each other. With your own type that is impossible: the types differ even if the strings match.
How to answer in an interview
Short answer: a context carries cancellation, a deadline and request-scoped
values down the call tree — so that work nobody wants any more can be stopped
rather than carried to the end. Cancellation is a signal, not an interrupt: it
works only where Done() is read. And the link in the tree is one-way —
cancellation travels down, never up and never sideways.
That is enough for a correct answer. What follows is what you add when the interviewer digs.
If the interviewer digs deeper
To "what is a context for" answer with three words and immediately a fourth. "Cancellation, deadlines, request-scoped values — and all of it a tree with a one-way link."
On the first argument name the reason, not the rule. "Each call has its own deadline; a struct field holds one context for every call, and a concurrent request gets somebody else's."
On cancel say "releases, not cancels". "It strikes the child off the
parent's list of children; without it the child stays there as long as the
parent lives — I measured 123 bytes per child."
On Canceled and DeadlineExceeded name the different behaviours. "Client
walked away — no point working; missed the deadline — that is a symptom for the
metrics. Code checking only Canceled lumps them together."
On cancellation say it is a signal. "A context interrupts nothing: a
goroutine that does not read Done() carries on. If a library takes no context,
there is nothing to cancel its call with."
On Value name both conditions and the cost. "Request-scoped data crossing
boundaries; the key is your own unexported type. And reading is dear: at twenty
layers eighteen times dearer than at one, so you read once at the entrance."
Next they ask
How do you finish writing data once the request is cancelled?
That is what context.WithoutCancel arrived for in Go 1.21: it returns a copy
of the context that is not cancelled with the original but keeps its values.
The typical case is recording a metric or an audit log after the client has gone.
It should not be confused with "ignore cancellation". The values (tracing, tenant
id) are kept while the deadline is not, so such an operation usually gets its
own short one: context.WithTimeout(context.WithoutCancel(ctx), time.Second).
How does WithTimeout differ from WithDeadline?
In nothing but notation: WithTimeout(ctx, d) is exactly
WithDeadline(ctx, time.Now().Add(d)). Both return a context that cancels
itself, and both require cancel.
The difference is what is more natural to express. WithDeadline fits when the
deadline comes from outside and has to be carried through several calls: a
child's deadline cannot be later than its parent's — the runtime takes the
smaller one. So a chain of WithTimeouts at every level does not "extend" the
deadline, it only shortens it.
Can you pass nil instead of a context?
No: the documentation says so outright, and in practice a nil context panics on
the first call to Done() or Value(). If there is no context yet, that is
context.TODO().
The difference between TODO and Background is not technical: both are empty.
TODO is a marker for a human and for a linter: "a real context belongs here,
but it is not yet clear where to get it". Finished code should contain no
TODO.
Is a context safe for several goroutines?
Yes, and the Go blog says so directly: contexts are safe for simultaneous use by
multiple goroutines. The reason is that a context is immutable — WithValue
and WithCancel do not modify the existing one but create a new node of the
tree.
That, incidentally, is where the cost of Value comes from: since the nodes are
immutable, the lookup walks up the chain rather than through a single map.
What does context.AfterFunc do?
It registers a function to run when the context is cancelled — added in Go 1.21.
Before it, the same task took a goroutine with
select { case <-ctx.Done(): ... }, and that cost a goroutine per wait.
The practical case: close a connection or release a resource when the request is
cancelled, without a dedicated goroutine. It returns a function that unregisters
the callback — that too must be called, or you get the same leak as with a
forgotten cancel.
Is a context in a struct never allowed?
The rule is there and it is unambiguous, but it has one recognised exception: a struct that itself represents one operation with its own lifetime — a queued request or a worker job, say. There the context is part of that operation's state rather than a hidden parameter.
The sign that tells an acceptable case from an unacceptable one: if the struct has several methods called at different times by different callers, a context in a field is a mistake. If the struct lives exactly as long as the operation, it is just another way of writing the same "first argument".
Common misconceptions
cancelling a context interrupts execution
Cancellation is a signal, not an interrupt: Done() closes and that is all. A goroutine that does not read it carries on as if nothing happened. If a library takes no context there is nothing to cancel its call with — a "run it in a goroutine and leave on a timeout" wrapper cancels only the wait.
cancel is only needed to cancel work
It strikes the child off the parent's list of children, so it is always called — success included. Measured: ten thousand children without cancel leave 123 bytes each, with cancel — 0. Hence the defer cancel() idiom right after creation.
a forgotten cancel leaks goroutines
The measurement does not support it: the goroutine count did not change in either case — WithTimeout starts a runtime timer, not a goroutine. What leaks is memory: the child stays in the parent's list, and everything it references is retained with it.
context.Canceled covers timeouts too
It does not: after a timeout errors.Is(err, context.Canceled) gives false while DeadlineExceeded gives true. These are different cases calling for different behaviour: the client walked away — no point working; a missed deadline — a symptom for the metrics.
cancelling a child will cancel the parent
The link is one-way: the run prints that after cancelling a child the parent's and the sibling's Err() is still nil. Cancellation goes down, not up and not sideways. That is exactly why a subtask can be cancelled without bringing down the whole request.
a context can be stored in a struct, it is more convenient
Each call has its own deadline and its own cancellation, while a struct field holds one context for every call — the first concurrent request gets somebody else's. There is one exception: a struct that itself represents a single operation with its own lifetime.
anything convenient can go into context.Value
The documentation demands two conditions at once: data that is request-scoped and that transits processes and APIs. Dependencies and settings are parameters and must be visible in the signature. And the key must be your own unexported type: with a string key two packages will silently overwrite each other.
Value is cheap, it can be read in a loop
Writing is cheap — one allocation. Reading is dear and the deeper the dearer: 6.13 ns at zero depth against 110.58 at twenty layers, with an ordinary struct field at 2.21 ns. Values are read once at the entrance to a handler and passed explicitly from there.
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.
Practice · predict the output
<-child.Done() fmt.Println(errors.Is(child.Err(), context.Canceled)) _, cancelC2 := context.WithCancel(p2) cancelC2() fmt.Println(p2.Err() == nil) <-tctx.Done() fmt.Println(errors.Is(tctx.Err(), context.DeadlineExceeded)) fmt.Println(errors.Is(tctx.Err(), context.Canceled)) fmt.Println(withUser.Value(userKey)) fmt.Println(base.Value(userKey))
Practice · estimate
Knowledge check
A goroutine runs a long loop and does not read ctx.Done(). The context was cancelled. What happens?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A context solves one problem: work that nobody wants any more does not know it. The client has gone or the deadline has passed, while the database query still runs and still holds resources. A context carries three things down the call tree — cancellation, a deadline, and values tied to the request — so that this news reaches the very bottom of the chain. It interrupts nothing by itself: cancellation is a signal, and it works only where it is read.
- Hence what surprises people. The link in the tree is one-way: cancelling a parent reaches every child, cancelling a child touches neither the parent nor the sibling — verified by running it.
cancelis not about cancelling a result but about releasing: it is always called, success included, or the child stays in the parent's list. Measured: ten thousand children withoutcancelleave 123 bytes each, withcancel— 0. AndCanceledandDeadlineExceededare different errors: after a timeouterrors.Is(err, context.Canceled)gives false, and code that checks onlyCanceledcannot tell a departed client from its own missed deadline. - Beyond that come the numbers, the machinery and the boundaries. A context is passed as the first argument rather than stored in a struct: every call has its own deadline.
Valuecarries request-scoped data that crosses package boundaries — tracing, deadlines, authentication, but not function parameters. Writing is cheap (one allocation) and reading is dear: the lookup walks up the chain of parents, and at twenty layers that is 18 times dearer than at zero, against 2.21 ns for a struct field. The ratio holds for this run on go1.24.7; the rule that comes out of it is to read once at the entrance, not to remember the number. Andcontext.Background()is never cancelled, itsDone()beingnil.
In fact
- Cancellation is a signal, not an interrupt:
Done()closes and that is all. A goroutine that does not read it carries on as if nothing happened. If a library takes no context there is nothing to cancel its call with — a "run it in a goroutine and leave on a timeout" wrapper cancels only the wait. - It strikes the child off the parent's list of children, so it is always called — success included. Measured: ten thousand children without cancel leave 123 bytes each, with cancel — 0. Hence the
defer cancel()idiom right after creation. - The measurement does not support it: the goroutine count did not change in either case —
WithTimeoutstarts a runtime timer, not a goroutine. What leaks is memory: the child stays in the parent's list, and everything it references is retained with it. - It does not: after a timeout
errors.Is(err, context.Canceled)gives false whileDeadlineExceededgives true. These are different cases calling for different behaviour: the client walked away — no point working; a missed deadline — a symptom for the metrics. - The link is one-way: the run prints that after cancelling a child the parent's and the sibling's
Err()is still nil. Cancellation goes down, not up and not sideways. That is exactly why a subtask can be cancelled without bringing down the whole request. - Each call has its own deadline and its own cancellation, while a struct field holds one context for every call — the first concurrent request gets somebody else's. There is one exception: a struct that itself represents a single operation with its own lifetime.
- The documentation demands two conditions at once: data that is request-scoped and that transits processes and APIs. Dependencies and settings are parameters and must be visible in the signature. And the key must be your own unexported type: with a string key two packages will silently overwrite each other.
- Writing is cheap — one allocation. Reading is dear and the deeper the dearer: 6.13 ns at zero depth against 110.58 at twenty layers, with an ordinary struct field at 2.21 ns. Values are read once at the entrance to a handler and passed explicitly from there.
What is covered
- What is really being asked
- Base: why the work needs to know the request is no longer wanted
- Mechanism 1: four purposes that get conflated
- Mechanism 2: cancel is about releasing, not about cancelling
- Mechanism 3: a deadline is cancellation too, for a different reason
- Mechanism 4: a context interrupts nothing
- Mechanism 5: what it looks like along a whole request
- Deeper: Value is cheap to write and dear to read
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
4 SOURCES
- The context packageOfficial documentation. The rules most often quoted imprecisely. On where a context belongs: «Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx». On cancel: «Failing to call the CancelFunc leaks the child and its children until the parent is canceled or the timer fires». On Value: «Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions».https://pkg.go.dev/context
- The context package — Err, Canceled, DeadlineExceededOfficial documentation. The distinction that breaks timeout handling: «If Done is not yet closed, Err returns nil. If Done is closed, Err returns a non-nil error explaining why: DeadlineExceeded if the context's deadline passed, or Canceled if the context was canceled for some other reason». And on Background: «Background returns a non-nil, empty Context. It is never canceled, has no values, and has no deadline».https://pkg.go.dev/context#Context
- Go Concurrency Patterns: Context — the Go blogOfficial documentation. The original rationale for the model: «At Google, we require that Go programmers pass a Context parameter as the first argument to every function on the call path between incoming and outgoing requests». And on safety: «Contexts are safe for simultaneous use by multiple goroutines».https://go.dev/blog/context
- Go 1.21 release notes — context.WithoutCancel and AfterFuncOfficial documentation. Additions asked about at more senior levels: «WithoutCancel returns a copy of a context that is not canceled when the original is canceled» and «AfterFunc registers a function to run when a context is canceled». The first answers a common practical question: how to finish writing to a database once the request has already been cancelled.https://go.dev/doc/go1.21