Deep Engineering
Intermediate·Published·30 MIN

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 cancel0. 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.

Where to start
Before this lesson it is enough to understand
  • 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.
You do not need to know in advance
  • WithCancel, WithTimeout, WithValue, Done(), Err() — all explained on the way;
  • how Canceled differs from DeadlineExceeded, what WithoutCancel and AfterFunc are, and the nanoseconds from the measurements.

What is really being asked

The ladder is almost always this one:

  1. "What is a context for?" — the warm-up: cancellation, deadlines, request-scoped values.
  2. "Why the first argument rather than a struct field?" — the substance starts here.
  3. "What does cancel do and why is defer cancel() written even on success?"
  4. "How does Canceled differ from DeadlineExceeded?" — the question timeout handling breaks on.
  5. "What goes into Value?" — and why "whatever is convenient" is wrong.
  6. "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:

  1. cancellation — the news that the work is no longer wanted;
  2. a deadline — the moment after which it is not wanted in any case;
  3. 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.

GO
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:

purposeset bywho uses it
cancellation — say the work is no longer wantedWithCancelwhoever reads Done()
a deadline — bound the time from aboveWithTimeout, WithDeadlinethe same
propagation — carry both through every callthe first ctx argumentevery function along the way
request-scoped valuesWithValuewhoever 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.

language contractGuarantees of the package: independent of the Go version and of the machine. Numbers appear in the «Deeper» section.

And the frame everything else is derived from: a context is a tree of signals. Not a store, and not a means of interruption.

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 context package

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 context package

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 cancel123 bytes
with cancel0
measured observationbench/gocontext/internals.go, go1.24.7 linux/amd64. One hundred and twenty-three bytes is what was left per child in this run; another version and another kind of context will have their own figure. The rule out of it is not «123» but «without cancel the child is not collected while the parent lives».

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.

implementation detail · Go 1.24The parent's list of children, and a timer instead of a goroutine, are how the package is built today. The package promises something else and something smaller: that failing to call the CancelFunc leaks the child. What exactly leaks is a detail that has changed before; the duty to call cancel does not depend on it.

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 context package — Err

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:

GO
// 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.

The context package

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 valueValue
06.13 ns×1.0
530.30×4.9
1051.80×8.4
20110.58×18.0
a struct field2.21for scale
measured observationbench/gocontext/internals.go, go1.24.7 linux/amd64. The ratio holds for this ladder of depths on this machine: your nanoseconds and your ×N will be your own. What does not depend on the machine is the sign: the cost grows with depth, because the lookup walks a chain rather than a map.

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.

GO
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

Next they ask

How do you finish writing data once the request is cancelled?

Short answer

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).

Next they ask

How does WithTimeout differ from WithDeadline?

Short answer

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.

Next they ask

Can you pass nil instead of a context?

Short answer

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.

Next they ask

Is a context safe for several goroutines?

Short answer

Yes, and the Go blog says so directly: contexts are safe for simultaneous use by multiple goroutines. The reason is that a context is immutableWithValue 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.

Next they ask

What does context.AfterFunc do?

Short answer

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.

Next they ask

Is a context in a struct never allowed?

Short answer

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

Claim

cancelling a context interrupts execution

Actually

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.

Claim

cancel is only needed to cancel work

Actually

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.

Claim

a forgotten cancel leaks goroutines

Actually

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.

Claim

context.Canceled covers timeouts too

Actually

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.

Claim

cancelling a child will cancel the parent

Actually

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.

Claim

a context can be stored in a struct, it is more convenient

Actually

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.

Claim

anything convenient can go into context.Value

Actually

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.

Claim

Value is cheap, it can be read in a loop

Actually

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

Will cancelling the parent reach the child, will cancelling the child affect the parent, what error does a timeout give — and is a child's value visible to the parent?
<-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

Reading the same value out of a context. How many times dearer is it with ten WithValue layers above it than with one?
times

Knowledge check

Question 1 of 6

A goroutine runs a long loop and does not read ctx.Done(). The context was cancelled. What happens?

Sources & further reading

4 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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