Context Internals — Junior¶
Table of Contents¶
- Why Look Inside Context
- The Four-Method Interface
- What Each Method Returns
- The Six Concrete Types
- The Singletons: Background and TODO
- The Workhorse: cancelCtx
- Adding a Deadline: timerCtx
- Adding a Value: valueCtx
- Go 1.21+ Additions
- Where the Source Lives
- A Tiny Picture of Allocation
- A First Look at the cancelCtx Picture
- Watching the Done Channel Close
- The CancelFunc Closure
- What a Tree of Contexts Looks Like
- How
ValueWalks the Tree - Why Many Small Allocations Matter
- Reading the Source in 30 Minutes
- Comparison With Other Languages
- A Brief History
- Common Misconceptions
- What to Read Next
Why Look Inside Context¶
You can use context for years without opening src/context/context.go. It works, it does what the documentation promises, you do not need to know how it works. So why this page?
Because every now and then a context mystery hits you that the documentation cannot answer:
- "Why does
ctx.Valueget slower in my deep middleware stack?" - "Why did
pproffind an extra goroutine sitting inpropagateCancel?" - "Why is
WithTimeoutallocating two objects instead of one?" - "Why does
Cause(ctx)returnnileven when the parent had a cause set?"
All four answers are in the source. The context package is short, readable, and well-organised. By the end of this junior page you will know which type runs in each call, where the allocations happen, and how ctx.Done() actually closes. Internal knowledge gives you a model that survives surprises.
This page stays beginner-friendly. We do not yet trace every mu.Lock. We tour the file, name the types, and watch how the basic objects behave. The middle and senior pages go deeper.
The Four-Method Interface¶
The whole context package builds on four method names. Here is the full interface, copied verbatim from src/context/context.go:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
That is the entire API surface that callers care about. Every concrete context inside the package implements those four methods. Every helper (WithCancel, WithTimeout, WithValue, etc.) returns something that satisfies this interface.
The first thing to notice: the interface has no Cancel() method. Cancellation is not something callers do through the context. It is something the owner of the context does through a separate CancelFunc returned alongside the context. The interface is read-only for callers.
The second thing to notice: there is no Parent() method either. A child cannot ask its parent for anything except through Value. The chain is internal and (mostly) hidden.
What Each Method Returns¶
Each method has a precise contract. Let us walk through them one at a time.
Deadline() (time.Time, bool)¶
Returns the absolute wall-clock time at which work tied to this context should stop, plus a boolean saying whether a deadline is set at all.
deadline, ok := ctx.Deadline()
if !ok {
// no deadline; work as long as you like
} else {
// schedule yourself to stop by `deadline`
}
For Background() and TODO(), the returned bool is false and the time is the zero time.Time. For a context returned by WithDeadline(parent, d), the bool is true and the time is d (or parent's deadline if that one is earlier).
Done() <-chan struct{}¶
Returns a channel that closes when the context is canceled. Reading from a closed channel succeeds instantly, so a select { case <-ctx.Done(): ... } returns the moment cancellation happens.
For Background() and TODO(), Done() returns nil. A nil channel in a select is permanently blocked. That is by design: those contexts never cancel.
For a cancelCtx, the channel is created lazily, the first time you call Done(). We will see why in the next section.
Err() error¶
Tells you why the context is canceled. The three possible non-nil values:
| Value | Meaning |
|---|---|
nil | Not canceled (yet) |
context.Canceled | Someone called cancel() |
context.DeadlineExceeded | The deadline elapsed |
Custom subclasses can in principle return other errors, but the standard library never does.
Value(key any) any¶
Looks up a request-scoped value by key. Returns nil if no value is found. The lookup walks the chain of contexts from the leaf up toward Background, returning the first match.
This is the one method whose cost is more than O(1). We will return to it on the middle page.
The Six Concrete Types¶
Open src/context/context.go and search for type … struct. You will find these six implementations:
emptyCtx // base for the two singletons
backgroundCtx // returned by Background()
todoCtx // returned by TODO()
cancelCtx // WithCancel / WithCancelCause
timerCtx // WithDeadline / WithTimeout (embeds cancelCtx)
valueCtx // WithValue
And from Go 1.21+:
withoutCancelCtx // WithoutCancel
afterFuncCtx // AfterFunc registration (embeds cancelCtx)
stopCtx // internal helper for AfterFunc parents
That is the entire universe. Nine types, fewer than 800 lines of source. The whole package is something a junior engineer can read in an evening.
Here is a quick map of what each one owns:
| Type | Fields | Allocates? |
|---|---|---|
backgroundCtx | none (singleton) | once at init |
todoCtx | none (singleton) | once at init |
cancelCtx | parent, mu, done, children, err, cause | one struct |
timerCtx | embeds cancelCtx + timer + deadline | one struct + one timer |
valueCtx | parent, key, val | one small struct |
withoutCancelCtx | parent | one tiny wrapper |
afterFuncCtx | embeds cancelCtx + once + f | one struct |
stopCtx | parent + stop func | one wrapper |
When you call context.WithTimeout(parent, 200*time.Millisecond), you are not getting a generic context. You are getting a pointer to a timerCtx, which contains a cancelCtx, which contains the bookkeeping for the cancellation tree, plus a time.Timer armed to fire in 200 ms.
The Singletons: Background and TODO¶
Both Background() and TODO() return interface values that wrap a tiny empty struct. The relevant lines:
type emptyCtx struct{}
func (emptyCtx) Deadline() (deadline time.Time, ok bool) { return }
func (emptyCtx) Done() <-chan struct{} { return nil }
func (emptyCtx) Err() error { return nil }
func (emptyCtx) Value(key any) any { return nil }
type backgroundCtx struct{ emptyCtx }
type todoCtx struct{ emptyCtx }
Both backgroundCtx and todoCtx embed emptyCtx, inheriting its four methods. The only difference is their String() methods:
func (backgroundCtx) String() string { return "context.Background" }
func (todoCtx) String() string { return "context.TODO" }
Background() and TODO() are functions but they always return the same value:
In Go, returning a zero-sized struct allocates nothing — the value lives in a single global memory slot. So calling Background() a million times allocates zero bytes. The compiler may even inline the call.
This is why Background() is "free." It is the only free constructor in the whole package.
The Workhorse: cancelCtx¶
Almost everything interesting in context is in this struct:
type cancelCtx struct {
Context // the parent
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily
children map[canceler]struct{} // set to nil by the first cancel call
err atomic.Value // set to non-nil by the first cancel call
cause error // set to non-nil by the first cancel call
}
Five interesting fields plus the embedded parent. Let us tour them:
Context(embedded) — the parent. Embedding meanscancelCtxautomatically delegatesDeadline,Done,Err, andValueto the parent unless it overrides them.cancelCtxoverridesDone,Err, andValue;Deadlineis not overridden so it forwards to the parent (which is exactly whatWithCancelshould do —WithCanceldoes not change the deadline).mu— a mutex protecting the mutable fields below.done— anatomic.Valuethat, once populated, holds achan struct{}. Until the first call toDone(), this is empty (zero) — the channel is not allocated yet.children— amap[canceler]struct{}of derived contexts. Each child registers itself here so that when this context cancels, it can in turn cancel all children.err— anatomic.Valuethat holds the reason for cancellation (CanceledorDeadlineExceeded).cause— the optional Go 1.20+ cause error.
What WithCancel Returns¶
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
func withCancel(parent Context) *cancelCtx {
if parent == nil { panic("...") }
c := &cancelCtx{}
c.propagateCancel(parent, c)
return c
}
Three things happen:
- A fresh
cancelCtx{}is heap-allocated. propagateCancelregisters this new context with its parent so that parent cancellation flows down.- A closure is returned that calls
c.cancel(true, Canceled, nil).
The returned CancelFunc is small — Go closures share an underlying object, so the cancel closure costs roughly one pointer plus the function pointer.
Why Done() Is Lazy¶
Reread the body:
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}
The first time Done() is called, the channel is created with make(chan struct{}). Until then, the field is empty and no channel exists.
This matters. Many short-lived contexts never have their Done channel observed — for instance, when a downstream call completes before select { case <-ctx.Done() } ever fires. In those cases, we save the cost of channel allocation entirely.
When cancel runs and done is still empty, the runtime substitutes a pre-created closedchan:
So if you call cancel() and only later call Done(), you do not pay for a fresh channel: you get the package-global closedchan, which is already closed. Receiving from it returns immediately. Cheap and correct.
Adding a Deadline: timerCtx¶
WithDeadline and WithTimeout both produce a timerCtx:
It embeds cancelCtx, which means it gets all the cancellation machinery for free. It adds:
- A
time.Timerthat will fire at the deadline and callcancel(true, DeadlineExceeded, cause). - The actual
time.Timedeadline value (so thatDeadline()can return it instead of forwarding to the parent).
The Deadline() method is overridden:
And the cancel method takes care of stopping the timer:
func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
c.cancelCtx.cancel(false, err, cause)
if removeFromParent {
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
This is why calling the returned CancelFunc is important even if the work completed early: the underlying time.Timer still has a goroutine inside it scheduled to fire. Stopping it releases that timer slot in the runtime's timer heap.
What an Allocation Looks Like¶
A single context.WithTimeout(parent, 200*time.Millisecond) performs roughly:
- One heap allocation for the
timerCtxstruct. - One call to
time.AfterFunc(dur, …), which allocates atime.Timerand a runtime timer record. - A small closure for the
CancelFunc.
So at least three heap-touching events. In a tight hot path at 200k req/s, this becomes measurable. That is why high-performance services avoid repeated WithTimeout calls per inner sub-call.
Adding a Value: valueCtx¶
Three fields: the embedded parent, the key, and the value. That is the smallest possible carrier.
func WithValue(parent Context, key, val any) Context {
// ... validity checks ...
return &valueCtx{parent, key, val}
}
One allocation, four pointer-sized fields (parent interface = 2 words, key any = 2 words, val any = 2 words; ignoring alignment). Cheap to construct.
Why Lookup Is O(depth)¶
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}
If the key matches, return. Otherwise, recurse into the parent. The actual recursion is in the unexported value function which is a single for loop over the chain.
So ctx.Value(k) cost grows linearly with the number of WithValue ancestors you have to walk through before you find your match. If you have 6 values stacked and you look up the deepest one, you walk all 6.
This is the source of the rule of thumb: do not abuse context.WithValue for many small fields. Stack them into one struct, store the struct as one value.
Go 1.21+ Additions¶
Three new shapes were added in Go 1.21:
WithoutCancel(parent) Context¶
Returns a context that does not inherit cancellation but does inherit values. The implementation is small:
type withoutCancelCtx struct {
c Context
}
func (withoutCancelCtx) Deadline() (deadline time.Time, ok bool) { return }
func (withoutCancelCtx) Done() <-chan struct{} { return nil }
func (withoutCancelCtx) Err() error { return nil }
func (c withoutCancelCtx) Value(key any) any {
return value(c, key)
}
Done returns nil (just like Background). Err returns nil. Value forwards. So the wrapper behaves like a Background that happens to carry values up the chain.
Use case: a long-running side task spawned from a request handler that should not be canceled when the request ends.
AfterFunc(ctx, f) func() bool¶
Schedules f to run after ctx is canceled. Returns a stop function that aborts the registration. Implemented as an afterFuncCtx:
We will not unpack this yet. The middle page covers it.
WithDeadlineCause(parent, d, cause) / WithTimeoutCause(parent, t, cause)¶
Variants of WithDeadline and WithTimeout that attach an explicit cause error to be returned by context.Cause(ctx) after the deadline fires. Useful for diagnostics — "this canceled because the user clicked stop" rather than just "deadline exceeded".
These do not introduce new types. They use the same timerCtx and stash the cause in the cancellation call.
Where the Source Lives¶
The whole package is one file:
On a Linux box with Go installed, that is usually /usr/local/go/src/context/context.go or ~/sdk/go1.22/src/context/context.go. On macOS with Homebrew, it is somewhere like /opt/homebrew/Cellar/go/.../libexec/src/context/context.go. The file is roughly 800 lines including comments — short by Go standard-library standards.
Also relevant:
$GOROOT/src/context/x_test.go — public-API tests
$GOROOT/src/context/context_test.go — internal tests
$GOROOT/src/context/benchmark_test.go — micro-benchmarks
If you want to see how the maintainers exercise the package, the tests are the second thing to read after the package itself.
A Tiny Picture of Allocation¶
Let us add up the allocations for a typical request:
ctx := context.Background() // 0 alloc
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) // ~3 (struct + timer + closure)
defer cancel()
ctx = context.WithValue(ctx, traceKey{}, "abc") // 1 (valueCtx)
ctx = context.WithValue(ctx, userKey{}, 42) // 1 (valueCtx)
// pass to handlers ...
Five allocations for one request. Multiply by 50,000 requests per second and that is 250,000 allocations per second from contexts alone. Not catastrophic, but worth a budget line. We will return to this on the optimize page.
You can verify with go test -bench . -benchmem:
BenchmarkWithTimeout-8 3000000 400 ns/op 200 B/op 3 allocs/op
BenchmarkWithValue-8 50000000 25 ns/op 32 B/op 1 allocs/op
(Actual numbers vary by Go version and CPU.)
A First Look at the cancelCtx Picture¶
Picture a cancelCtx as a small box with five labelled slots:
┌──────────────────────────────────────┐
│ *cancelCtx │
│ │
│ parent ───────────► other ctx │
│ mu [sync.Mutex] │
│ done [chan struct{} or nil] │
│ children {child1, child2, ...} │
│ err [nil before cancel] │
│ cause [nil or user-supplied err] │
└──────────────────────────────────────┘
Five things to remember:
- parent — where the context came from. Could be another cancelCtx, a backgroundCtx, a valueCtx, anything. Stored as an interface (so we lose the concrete type unless we look it up via
Value). - mu — a mutex. We do not need to know exactly when it is held; the package handles that. Just know that all the dangerous writes happen under this lock.
- done — the channel that closes on cancel. Starts empty. Allocated when first needed.
- children — a map of contexts that derive from us. When we cancel, we cancel each child too.
- err — the cancellation reason. Before cancel:
nil. After:CanceledorDeadlineExceeded. - cause — optional explanation (e.g., "user clicked stop").
Note that the picture does not include a deadline. cancelCtx itself does not have one. timerCtx adds the deadline by embedding cancelCtx plus its own timer field.
A Tiny Concrete Example¶
ctx, cancel := context.WithCancel(context.Background())
// At this point:
// ctx is a *cancelCtx
// ctx.parent is context.Background() (a backgroundCtx)
// ctx.done is nil (no one has called Done() yet)
// ctx.children is nil (no children yet)
// ctx.err is nil
// ctx.cause is nil
<-ctx.Done()
// Allocating ctx.done is forced; it is now a chan struct{}.
cancel()
// Now:
// ctx.err is Canceled
// ctx.cause is Canceled (fell back to err)
// ctx.done is closed
// ctx.children is nil (was already nil)
This is the shortest possible cancelCtx lifecycle. Every other usage builds on it.
Watching the Done Channel Close¶
To feel how Done() actually behaves, run this little program:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(500 * time.Millisecond)
fmt.Println("calling cancel()")
cancel()
}()
fmt.Println("waiting on ctx.Done() ...")
<-ctx.Done()
fmt.Println("ctx.Done() fired; ctx.Err() =", ctx.Err())
}
Output:
What happened internally?
context.WithCancel(context.Background())constructed a*cancelCtxwith parent = Background. Allocations: one cancelCtx struct, one CancelFunc closure. No channel yet.- The main goroutine called
ctx.Done(). This is the first call, so the lazy code path ran: under the mutex, the package allocatedmake(chan struct{})and stored it. Now the channel exists. - The main goroutine then started receiving from the channel. Because the channel is not closed and not buffered, the goroutine parked.
- After 500 ms, the background goroutine called
cancel(). This invokedc.cancel(true, Canceled, nil). The package took the mutex, seterr = Canceled, setcause = Canceled, closed the channel, set children = nil, released the mutex, then calledremoveChildagainst the parent (Background — nothing to do). - Closing the channel unparked our main goroutine. The receive returned.
- The main goroutine called
ctx.Err(). The atomic-loadederrwas non-nil, so the method received from the (now closed) channel, then returnedCanceled.
The whole story takes microseconds. The mental model — "Done is a channel that closes; Err tells you why" — covers it from outside.
The CancelFunc Closure¶
When WithCancel returns, the second value is a CancelFunc. Where does it come from?
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
It's a closure. func() { c.cancel(true, Canceled, nil) } captures c (the new cancelCtx pointer) and ignores its environment. The compiler allocates this closure on the heap — that's our second allocation in WithCancel.
You can store this closure, call it from anywhere, call it many times. It is safe to call concurrently. After the first call, subsequent calls are no-ops (because c.cancel checks c.err.Load() != nil at the top and returns immediately).
defer cancel() is the Standard Idiom¶
You will see this on every page of every Go tutorial:
Why "defer"? Because forgetting to call cancel() leaks the cancelCtx forever (the parent's children map still points at it). Defer guarantees the call happens when the function returns, regardless of which branch we exit on.
defer cancel() is the single most important convention in the package. Internalise it.
What a Tree of Contexts Looks Like¶
A real handler might build a small tree:
func handle(req *http.Request) {
ctx := req.Context() // root
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
ctx = context.WithValue(ctx, traceKey{}, traceFrom(req))
go fetchA(ctx)
go fetchB(ctx)
}
The tree looks like:
req.Context() (cancelCtx behind the scenes, from net/http)
└─ *timerCtx (our 200ms wrap)
└─ *valueCtx (key=traceKey)
├─ used by fetchA
└─ used by fetchB
fetchA and fetchB both see the same context. If fetchA derives more contexts (a WithTimeout, a WithValue), the tree grows beneath it. Each leaf only knows its own pointer up; it does not see siblings.
Cancellation Flows Down¶
If req is canceled (client disconnect), the server's context cancels, which cancels our timerCtx (because it registered in the request's children), which cancels nothing further (valueCtx is uncancellable on its own — it inherits Done from the timerCtx).
fetchA and fetchB both observe ctx.Done() close. They abandon their work and return.
Values Flow Down Too¶
A handler deeper in the call graph can write:
This walks up from wherever it is to the valueCtx we created and finds the trace ID. The walk crosses any intermediate contexts (timerCtx, cancelCtx) transparently because their Value methods delegate to their parents.
This is the propagation model: cancellation down, values up (when looked up).
How Value Walks the Tree¶
To make the upward walk concrete, here is a slightly enriched chain:
context.Background() ← top
→ *valueCtx (key=A, val=1)
→ *valueCtx (key=B, val=2)
→ *cancelCtx
→ *valueCtx (key=C, val=3)
→ leaf
Calling leaf.Value(A):
- leaf is a
*valueCtx. Its key is C, not A. Continue to leaf.Context. *cancelCtx. Its embedded Context is the parent. Continue.*valueCtx. Its key is B, not A. Continue.*valueCtx. Its key is A! Return its value, 1.
That walk took 4 hops to find a 4-deep ancestor. O(depth).
Calling leaf.Value("Z") for a missing key:
- leaf — C ≠ Z. Continue.
- cancelCtx — continue.
- valueCtx — B ≠ Z. Continue.
- valueCtx — A ≠ Z. Continue.
- backgroundCtx — terminate, return nil.
5 hops for a miss. Slightly longer because we have to walk all the way to the root.
If you do hundreds of these per request, depth times count adds up. That is why the consolidation advice exists: stack many fields into one struct so the chain stays shallow.
Why Many Small Allocations Matter¶
A common reaction from new Go developers: "These are tiny allocations. Why care?"
Two reasons.
1. They show up in profiles¶
Run pprof against a high-throughput service and look at the allocation profile:
File: server
Type: alloc_space
Showing top 10 nodes out of 200
flat flat% sum% cum cum%
1.50GB 12.34% 12.34% 1.50GB 12.34% context.WithTimeout
1.20GB 9.87% 22.21% 1.20GB 9.87% context.WithValue
...
context.WithTimeout allocates 1.5 GB of working set over the profile window. The GC has to scan that. Each cycle through the heap costs CPU. At some point, the GC scan time dominates your service's CPU.
2. They live on the heap and pressure GC¶
Stack allocations are essentially free. Heap allocations cost (a) the time to allocate, (b) the GC's time to track them, (c) eventually the time to free them. The bigger your heap, the longer GC pauses get.
For a service with a strict latency SLO (say p99 = 5 ms), GC pauses bigger than 1-2 ms can violate the SLO. Heap reduction directly improves tail latency.
3. Channels and goroutines are the heaviest pieces¶
Within context, the biggest items are:
| Object | Approx size |
|---|---|
chan struct{} | 96 bytes + runtime hchan metadata |
| Forwarder goroutine | ~2 KB stack while parked |
time.Timer | ~64 bytes + runtime timer record |
cancelCtx struct | ~80 bytes |
valueCtx struct | ~48 bytes |
withoutCancelCtx | ~16 bytes |
Channels and goroutines are the heavy hitters. The lazy done channel optimisation is very important — it saves the 96-byte allocation per context that doesn't need it.
Reading the Source in 30 Minutes¶
If this page sparked your curiosity, here is a 30-minute reading plan:
- Minutes 0–5: Open
src/context/context.go. Skim the top doc comment for the package overview. - Minutes 5–10: Read the
Contextinterface definition and its doc comment. Note the four methods. - Minutes 10–15: Read
emptyCtx,backgroundCtx,todoCtx, and theBackground()/TODO()functions. Two pages, mostly comments. - Minutes 15–20: Read
cancelCtx(the struct only, not the methods). Note the five fields. - Minutes 20–25: Read
cancelCtx.DoneandcancelCtx.Err. Note the double-checked locking. - Minutes 25–30: Read
cancelCtx.cancel. Note the order: take lock, set err, close done, cascade, nil children, release lock.
You have now read the most important 200 lines of the package. The other 600 are extensions of these patterns. The middle and senior pages cover them at depth.
Comparison With Other Languages¶
Where does context sit in the broader world?
| Language | Closest concept | Cancellation? | Deadline? | Values? |
|---|---|---|---|---|
| Go | context.Context | Yes | Yes | Yes |
| C++ | std::stop_token (C++20) | Yes | No | No |
| C++ | std::future | No (just await) | Sometimes | No |
| Java | CompletableFuture + cancel | Yes | Yes | No |
| Java | ThreadLocal | No | No | Yes |
| Rust | tokio_util::sync::CancellationToken | Yes | No | No |
| Rust | tokio::time::timeout | Implicit | Yes | No |
| Rust | tracing::Span + extensions | No | No | Yes |
| C# | CancellationToken | Yes | No (via CancelAfter) | No |
| Python | asyncio.Task.cancel() | Yes | Via wait_for | No |
A few observations:
- Go is unusual in fusing all three concerns (cancel, deadline, value-bag) into one type. Most ecosystems split them across distinct primitives.
- The tree-of-cancellations model is shared with C# and Rust's
CancellationToken, both of which support hierarchical linkage similar toWithCancel. - Values-in-context is essentially what
ThreadLocalis for Java, but local to a call chain rather than to a thread, which matches Go's "context flows with the request" model better. - No language fully replicates
WithoutCancel. The closest is C#CancellationTokenSource(token, none)patterns; in Java you would build a new orphaned token explicitly.
A Brief History¶
A short timeline so you know where this all came from:
- 2014: Sameer Ajmani and team at Google publish the original
contextpackage asgolang.org/x/net/context. Goal: a unified way to plumb cancellation and deadlines through Google's RPC handlers. - August 2016 (Go 1.7):
contextjoins the standard library. The API and semantics standardise across the ecosystem. - 2020 (Go 1.16):
signal.NotifyContextlands — convenience for "cancel on SIGINT/SIGTERM". - 2022 (Go 1.20):
WithCancelCauseandCause(ctx)introduce causal cancellation. You can now tag why a context was canceled. - August 2023 (Go 1.21):
AfterFunc,WithoutCancel,WithDeadlineCause,WithTimeoutCause. The "extension pack."
The interface itself has stayed unchanged since 2016. Every addition has been a new free-function on top of the existing four methods.
Reading the original Sameer Ajmani blog post ("Go Concurrency Patterns: Context", 2014) is still worthwhile — the motivation is the same today as it was then.
Common Misconceptions¶
A short collection of beliefs that new Go engineers often hold about context. Each is incorrect; each correction is rooted in the internals we toured.
"Context cancels stop running goroutines."¶
No. Cancellation only closes the Done channel. A goroutine that does not select on ctx.Done() is unaffected. Cancellation is a signal, not a kill.
If you want a goroutine to actually stop, you have to plumb ctx.Done() into its select statement (or into a blocking operation that supports context, like net.Conn.SetReadDeadline).
"Calling cancel() frees all the memory immediately."¶
Partly. It does trigger freeing — it nils the children map, which removes references to child contexts. But the child contexts themselves still exist until the GC sweeps them. And the memory of the parent's struct still exists until its parent's reference is dropped.
GC is non-deterministic. Memory pressure relief happens, but not instantly.
"Background() and TODO() do the same thing — why have both?"¶
Semantically yes, intent-wise no. They are equivalent in behaviour but distinct in type. The package authors chose to have TODO() so that code reviewers can grep for incomplete plumbing without having to read every Background() call to judge whether it is final.
"Context is just a thread-local."¶
No. A ThreadLocal (Java) is keyed by the thread that runs the call; in Go, a context is explicitly passed to each function. The difference matters when work crosses goroutines: in Java, the new thread starts with empty ThreadLocal unless you copy; in Go, the called function gets exactly the context you handed it. The flow is explicit.
This is one of Go's deliberate design choices. It is verbose (every function takes ctx context.Context) but it avoids the action-at-a-distance bugs that thread-locals create.
"WithValue is for any data I want to pass through."¶
No. WithValue is for request-scoped data that crosses API boundaries. Optional parameters, configuration, computed results should still be regular function arguments. The godoc states this explicitly.
The reason is twofold: Value lookups are slow (chain walks) and type-unsafe (any everywhere). Regular arguments are fast and statically checked.
"I should pass nil as a context to mean 'no context'."¶
No. All public functions in the standard library panic if you pass nil. Use context.TODO() for placeholder, context.Background() for "this really has no enclosing context."
Inside the context package itself, the constructors WithCancel, WithDeadline, etc., explicitly check for nil and panic:
This is a fail-fast design. A nil context propagating through your code would crash in obscure places; the panic at the source makes the bug obvious.
"I can store the context in a struct field for later use."¶
Strongly discouraged. The Go community's advice is "contexts flow through function arguments, not data structures." Storing a context in a struct usually means the struct's lifetime decides when the context applies, which decouples cancellation from the original request's lifetime — exactly what context was supposed to make explicit.
There are rare valid exceptions (e.g., a "session" object that genuinely lives for the duration of a request and is built once at request entry). In those cases, document loudly.
"Done() returns a buffered channel."¶
No. It returns an unbuffered channel of struct{}. The point is to be a closed-channel signal. Closing an unbuffered channel makes all receivers unblock simultaneously, which is exactly what cancellation wants. Buffering would be irrelevant — we never send on the channel, we only close it.
"I can re-open a context by creating a new cancel func."¶
No. The Done channel, once closed, cannot be reopened. The package does not support resume/restart semantics. If you need that, you need a different primitive (a custom chan struct{} with manual reset, or a sync.Cond).
"The context can tell me where it came from."¶
No. There is no ctx.Parent() method. The only way to access the parent is by calling ctx.Value(someKey) and hoping the parent has that key. The parent relationship is internal to the package.
This is intentional: callers should not depend on the tree structure. The contract is just the four methods.
"If I forget defer cancel(), the GC will clean up eventually."¶
Eventually, yes, but only after the parent context is also garbage-collected. If the parent is context.Background(), the parent lives forever and the child lives forever. If the parent is a request context, the child lives until the request ends — which is fine but wasteful.
For the request case, the leaked timer is the bigger problem. A leaked time.Timer from a WithTimeout sits in the runtime timer heap until it fires. Not GC-able.
So always defer cancel(). The cleanup is not free, and the GC cannot rescue you from logical leaks.
What to Read Next¶
If your goal is to use context correctly, you can stop here.
If your goal is to debug context-related bugs, head to the middle page: it covers propagateCancel, the parent-watcher goroutine, and the removeChild mechanism that breaks circular references when a child is canceled before its parent.
If your goal is to performance-tune a hot path, read the senior and optimize pages. They cover allocation-saving tricks, the fast path in parentCancelCtx, and the trade-offs of custom Context types.
If your goal is to rewrite the package (e.g., to prototype a new cancellation mechanism), read the professional page, which steps through every type and method line by line.
Bonus: Walking Through a Real Request¶
Let us trace what happens, step by step, when a request comes into an HTTP server. Open this in your head as you read along.
func main() {
rootCtx, rootCancel := signal.NotifyContext(
context.Background(),
syscall.SIGINT, syscall.SIGTERM,
)
defer rootCancel()
srv := &http.Server{
Addr: ":8080",
Handler: mux,
BaseContext: func(net.Listener) context.Context {
return rootCtx
},
}
srv.ListenAndServe()
}
Step 1: At process start¶
context.Background() returns a backgroundCtx{}. Zero allocations.
signal.NotifyContext(parent, ...) derives a child context that cancels on the listed signals. Internally, this is a cancelCtx plus a goroutine that watches a signal channel.
Allocations so far: one cancelCtx (~80 bytes), one channel (~96 bytes), one CancelFunc closure (~16 bytes), one goroutine.
Step 2: A request arrives¶
net/http accepts a connection and constructs a request. For each request, it derives a fresh *cancelCtx from the BaseContext (our rootCtx). It also wires up the connection so that when the client closes, the request's context cancels.
Allocations per request: one cancelCtx (~80 bytes), one CancelFunc closure (~16 bytes). Plus an entry in rootCtx.children (one map slot, maybe a map alloc if this is the first request).
Step 3: The handler runs¶
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
ctx = context.WithValue(ctx, traceKey{}, traceID(r))
result, err := doWork(ctx)
// ...
}
r.Context()returns the request's cancelCtx — no allocation.WithTimeoutallocates atimerCtx(~80 bytes), atime.Timer(~150 bytes), and a CancelFunc closure (~16 bytes). It also adds this timerCtx to the request'schildrenmap (one slot).WithValueallocates avalueCtx(~48 bytes).
Total per-handler allocations: ~310 bytes plus a timer record.
Step 4: doWork derives its own contexts¶
func doWork(ctx context.Context) (Result, error) {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
cacheVal, _ := cache.Get(cacheCtx, key)
// ...
}
Another timerCtx, another timer, another CancelFunc. Plus addition to ctx.children.
This is the cumulative cost: each layer of derivation adds heap pressure. At 50,000 requests per second with 6 derivations each, you allocate about 100 MB/sec from context plumbing alone.
Step 5: The request completes¶
The handler returns. Deferred cancel() calls fire in reverse order:
WithTimeout's cancel from insidedoWorkfires. The associated timerCtx'scancel(true, Canceled, nil)runs: takes mutex, sets err, closes done channel, drops children, releases mutex, callsremoveChildagainst its parent.WithTimeout's cancel from the handler fires. Same dance.http.Server's machinery calls the request context's cancel (the one created in step 2). Same dance.
At each step, removeChild cleans up the entry in the parent's children map. By the time the request is gone, no traces remain in rootCtx.children.
Step 6: Eventually, shutdown¶
When the process receives SIGTERM:
signal.NotifyContext's machinery cancelsrootCtx.- The cancel cascades: any in-flight request still has a children-map entry; each gets canceled.
- Active goroutines selecting on
Done()see it close. They return. srv.ListenAndServe()returns.mainexits.
The cascade is the package's contribution to graceful shutdown. Without context, you would have to manually track every long-running operation.
What This Tells You¶
A real handler creates ~6 contexts per request. Each context is small but the count adds up.
A real server starts maybe one root context plus the per-connection ones the standard library makes for you.
The lifecycle is short — milliseconds for the request context, the full process for the root.
Knowing this, you can predict where pprof will show context costs (in WithTimeout and WithValue), and you can reason about whether to consolidate (yes, if you see them in the top-10 alloc list).
One Last Thought: Why the Package Is So Small¶
The context package is ~800 lines. The runtime is 50,000+ lines. The net/http package is 30,000+ lines. Yet context is shipped with every Go binary and runs in every request handler.
Its smallness is its strength. There is not much that can go wrong. The whole package can be re-read in an afternoon. Bugs are findable in pull-request reviews. New features can be added without breaking old ones — and the history of the package proves it: from 2014 to 2024, every API addition has been backward-compatible.
The package is also old enough that every imaginable shape of bug has been found. By the time you encounter a strange behaviour, someone has filed an issue, the maintainers have discussed it, and the fix (if any) is in some recent release. The package's mailing-list archives are an underrated resource.
When learning Go, the context package is one of the few places where reading the source is more rewarding than reading the documentation. The doc is short and intentional; the source is short and beautiful. Treat it as a study text, not a black box.
What to Read Next, Concrete Plan¶
A learning sequence that builds on this page:
- Re-read this page with
src/context/context.goopen in another window. Cross-reference each diagram against the actual code. - Read middle.md to understand
propagateCancelandremoveChild. - Read senior.md to see the other types:
timerCtx,valueCtx,withoutCancelCtx,afterFuncCtx. - Read professional.md for the full source walkthrough.
- Skim specification.md for the formal contract — you will reference this when discussing custom Context implementations.
- Work tasks.md to internalise everything.
- Practice with find-bug.md to recognise pitfalls.
- Read optimize.md if and when your service profiles show context overhead.
If you do all eight, you will be among the small group of Go engineers who can confidently answer "what does context actually do?" with detailed, evidence-based reasoning.
Next: middle.md — propagation, the children map, and the parent watcher.