Go Context Internals
Go context interview notes. Covers: basic usage, cancellation and propagation, timeout and deadline, values, internals, memory model, common patterns and pitfalls.
Basic usage
A context carries three kinds of information across API boundaries: a cancellation signal, a deadline, and request-scoped data. Official definition: a Context carries a deadline, a cancellation signal, and other values across API boundaries.
The Context interface has only four methods:
Deadline(): returns the deadline. When none is set, ok is false.Done(): returns a channel that is closed when the context is canceled. Returns nil for contexts that can never be canceled.Err(): returns nil while Done is not yet closed; after close it returns the reason:context.Canceled(explicit cancel) orcontext.DeadlineExceeded(timeout).Value(key): looks up the value for key, returns nil if absent.
Two root contexts, both uncancelable:
context.Background(): the real root, created in main and initialization code.context.TODO(): a placeholder when you have not decided which context to use, for example a function not yet wired to the caller’s context.
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
select {
case <-ctx.Done():
fmt.Println(ctx.Err()) // context canceled
case <-time.After(time.Second):
fmt.Println("timeout")
}Running this prints context canceled.
Three passing rules (from the official docs):
- A context is the first argument of a function, usually named
ctx, never stored in a struct. - Don’t pass a nil context. When unsure, pass
context.TODO(). - The same context can be safely passed to multiple goroutines at the same time.
Cancellation
WithCancel(parent) returns a derived context and a cancel function:
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // releases resources when the function returnsCore properties of cancellation:
- Calling cancel closes the context’s Done channel; Err() returns
context.Canceledafterwards. - cancel is idempotent: only the first call has any effect.
- Cancellation propagates: when a parent is canceled, all derived children are canceled too.
- Canceling a child does not affect its parent or siblings.
- Not calling cancel costs resources: the WithTimeout timer and the goroutine registered by propagateCancel stay around (see internals below).
The right pattern is defer cancel() right after getting the cancel function, not remembering it later.
Timeout and deadline
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
<-ctx.Done()
fmt.Println(ctx.Err()) // context deadline exceededRunning this prints context deadline exceeded.
WithTimeout(parent, d)is a thin wrapper aroundWithDeadline(parent, time.Now().Add(d)).WithDeadline(parent, t)cancels automatically when the time comes; Err() returnscontext.DeadlineExceeded.- A manual cancel takes effect early; Err() returns
context.Canceled, not DeadlineExceeded. - When the parent’s deadline is earlier than the new one,
WithDeadlinedegenerates intoWithCancel(parent): the parent fires first, the child follows, same semantics, one less timer. - When the deadline has already passed (
dur <= 0), it cancels immediately without starting a timer.
The difference between Canceled and DeadlineExceeded: explicit cancel produces the former, automatic expiry the latter.
Values
type key int
var userKey key
ctx := context.WithValue(context.Background(), userKey, "nite")
fmt.Println(ctx.Value(userKey)) // nite
fmt.Println(ctx.Value(key(99))) // <nil>Running this prints nite and <nil>.
Rules:
- Only request-scoped data belongs here, not function arguments. Official wording: not for passing optional parameters to functions.
- The key must be comparable. Don’t use built-in types (string, int, …) as keys; define your own type to avoid collisions between packages.
- A nil or non-comparable key makes
WithValuepanic. - Lookup walks up the context chain: if not found at this level, ask the parent; return nil when the chain is exhausted. The longer the chain, the slower the lookup.
Internals
The source lives in src/context/context.go; it moved from x/net/context into the standard library in Go 1.7. The core is three structs.
cancelCtx
type cancelCtx struct {
Context
mu sync.Mutex // protects the following fields
done atomic.Value // chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set of child contexts, set to nil by the first cancel call
err atomic.Value // set by the first cancel call
cause error // set by the first cancel call
}Four points:
doneis created lazily: if nobody callsDone(), no channel is allocated. It is created on the first call toDone().Done()always returns the same channel across calls.Value(&cancelCtxKey)returns the context itself; this is how cancellation propagation finds the innermost cancelCtx (see propagateCancel).- children is a map; cancel iterates over all children and cancels each one.
Why is done an atomic.Value instead of a bool? done’s job is notification, not state. The state bit belongs to the err field: non-nil means canceled, and cancel() uses it for idempotency. A bool cannot notify:
select { case <-ctx.Done(): }needs an object you can block on; a bool can only be polled and does not compose with select.- Closing a channel wakes up every waiter; that is a broadcast. One context may be listened to by any number of goroutines. A bool is a single value with no notification ability.
- Closing a channel carries its own happens-before semantics (see the memory model section); a bool does not.
atomic.Value is a container for the channel, used for lazy creation and lock-free reads. The Done() implementation:
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{})
}Double-checked locking: a lock-free Load first, return on hit, take the lock to create on miss. Channels are heavyweight, and most contexts never have their Done() listened to; lazy creation saves those allocations. Every Done() on the hot path is a lock-free read; a plain channel field would need the mutex every time.
Historically done was created eagerly with make(chan struct{}) at construction, with Done() reading it lock-free, at the cost of allocating a channel for every cancelCtx (both in the x/net/context era and when Go 1.7 first moved it into the standard library). Go 1.9 switched to lazy creation guarded by a mutex, so every Done() took the lock; Go 1.20 replaced it with atomic.Value, making the read path lock-free.
The cancel() flow:
- If
erris already non-nil, return immediately; that is the idempotency. - Write err and cause.
- Close the done channel. If nobody ever called
Done(), done is nil; store a package-level shared closed channel (closedchan) instead, saving one allocation. - Iterate children and cancel each child context.
- Set children to nil.
- If needed, remove itself from the parent’s children.
propagateCancel: how cancellation spreads
WithCancel calls propagateCancel(parent, child) when creating a child context, attaching the child to the parent. Four branches, in order:
- Parent’s
Done()returns nil (parent can never be canceled): do nothing; the child will never be canceled by the parent. - Parent’s Done is already closed (parent is already canceled): the child is canceled immediately, inheriting the parent’s err and cause.
- Parent is a standard-library cancelCtx (found via
parentCancelCtx): add the child straight into the parent’s children map. When the parent cancels, it iterates and cancels all children. - Parent is a custom context implementation: start a goroutine that
selects on the parent’s Done and the child’s Done; whichever closes first, the goroutine exits. If the parent closes first, the child is canceled.
parentCancelCtx finds the innermost cancelCtx via parent.Value(&cancelCtxKey): valueCtx’s Value delegates to the parent, cancelCtx’s Value returns itself, so any derivation chain leads to the innermost cancelCtx. When that fails (for example a custom implementation wrapping a different Done channel), it falls back to the goroutine path. The goroutine exits once either side’s Done closes; if neither ever closes, the goroutine leaks — one of the underlying reasons “you must call cancel”.
That is the whole parent-child relationship:
| Scenario | Behavior |
|---|---|
| Parent canceled | Iterates children, cancels each one; err/cause inherited from parent |
| Child canceled | Only removes itself from the parent’s children; parent and siblings unaffected |
| Parent already canceled at creation | Child canceled immediately, never attached to children |
| Parent uncancelable at creation | No relationship established; child independent |
| New child after parent canceled | Goes through the “parent already canceled” branch, canceled immediately; children is nil, so nothing is attached |
Three mechanics:
- children lives only on cancelCtx (timerCtx embeds cancelCtx, so it counts too). valueCtx holds no children; when it is the parent,
parentCancelCtxsees through it and the child is attached to the innermost cancelCtx. - Propagation down calls
cancel(false, ...)on the child, which does not detach; after the iteration the parent sets children to nil, clearing the whole subtree’s relationship in one shot. An explicitcancel()goes throughcancel(true, ...), andremoveChilddetaches it from the parent. - A child canceled by its parent gets the parent’s err and cause (
child.cancel(false, parent.Err(), Cause(parent))), so parent and child agree onErr(), andCause()matches along the chain.
timerCtx: the carrier of WithDeadline
type timerCtx struct {
cancelCtx
timer *time.Timer // protected by cancelCtx.mu
deadline time.Time
}Embeds cancelCtx, adds a timer and a deadline:
WithDeadlinestarts the timer withtime.AfterFunc(dur, cancel); it cancels automatically when it fires.- If the deadline has already passed (
dur <= 0), no timer is started; it cancels immediately. - A manual cancel calls
timer.Stop()first, then runs the cancelCtx cancellation flow. Sodefer cancel()is not just the cancellation signal; it also stops the timer.
The full WithDeadline flow:
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent) // parent's deadline is earlier; degenerate to plain cancel
}
c := &timerCtx{deadline: d}
c.cancelCtx.propagateCancel(parent, c) // attach to parent first
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded, cause) // deadline already passed; cancel now
return c, func() { c.cancel(false, Canceled, nil) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err.Load() == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, cause)
})
}
return c, func() { c.cancel(true, Canceled, nil) }
}The timer starts under the lock, only after confirming err is nil: after propagateCancel the parent may already be canceled, and starting the timer then would be a wasted allocation; when the callback fires it would only hit the “already canceled” idempotent branch.
timerCtx.cancel overrides the embedded cancelCtx’s cancel:
func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
c.cancelCtx.cancel(false, err, cause) // broadcast first (close done, cancel children)
if removeFromParent {
removeChild(c.cancelCtx.Context, c) // then detach from the parent's children
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop() // stop the timer last, releasing resources
c.timer = nil
}
c.mu.Unlock()
}A manual cancel passes Canceled, the timeout callback passes DeadlineExceeded; the same cancellation flow, two error sources. timerCtx only overrides Deadline() (returns the stored deadline) and cancel(); Done()/Err() are reused from the embedded cancelCtx.
valueCtx: the carrier of WithValue
type valueCtx struct {
Context
key, val any
}Stores a single key-value pair; every other method delegates to the embedded parent context. Value() walks up when the key does not match at this level; that is the chain lookup.
WithValue has three panic checks: nil parent, nil key, non-comparable key (reflectlite.TypeOf(key).Comparable()). The comparability check happens at runtime, so passing a slice or map as key panics at the WithValue call, not later at Value() lookup time.
Value() lookup is not recursion; it is a loop with type switches (the value() function):
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case *timerCtx:
if key == &cancelCtxKey {
return &ctx.cancelCtx
}
c = ctx.Context
case backgroundCtx, todoCtx:
return nil
default:
return c.Value(key)
}
}
}Details:
- A loop instead of recursion; no stack growth no matter how long the chain.
- The
cancelCtxKeyhit is special-cased: cancelCtx returns itself, timerCtx returns its embedded cancelCtx. This is howparentCancelCtxsees through valueCtx to the innermost cancelCtx (see propagateCancel). - Reaching backgroundCtx/todoCtx returns nil; the chain is exhausted.
- An unknown custom context type falls back to calling its
Value(key), so custom implementations keep their behavior.
Additions since Go 1.21
WithCancelCause/Cause: cancel with an error as the cancellation cause, retrieved viaCause(ctx). When the parent cancels first, the child inherits the parent’s cause.AfterFunc: schedules a function to run when the context is canceled; the returned stop function can unregister it before cancellation.WithoutCancel: derives a context unaffected by parent cancellation; Done returns nil, Err always returns nil. Used for logging and background cleanup that must finish even when the parent dies.
Memory model
Closing the Done channel follows the channel close rule: the close happens-before any receive that observes the close. Writes done in cancel are therefore visible to goroutines after <-ctx.Done() returns — the cancellation signal itself carries the synchronization, no extra locking needed.
Two details:
- The docs note that Done may close asynchronously: the channel may close after the cancel function has returned.
- Before
Err()returns non-nil it waits for Done to close (in the source:<-c.Done()), keeping “Err non-nil” and “Done closed” strictly consistent.
Common patterns
Request-scoped context
In an HTTP service, the request’s context flows from the entry point down the whole call chain; every downstream function takes ctx. Client disconnect, timeout, or an explicit server cancel stops the whole chain together.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result, err := doWork(ctx)
// ...
}
func doWork(ctx context.Context) (Result, error) {
select {
case <-ctx.Done():
return Result{}, ctx.Err()
case result := <-workCh:
return result, nil
}
}Wrapping a slow operation with a timeout
When you cannot control a downstream operation, wrap it in WithTimeout to avoid waiting forever:
ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
result, err := slowService(ctx)Note defer cancel(): if the slow operation returns early, the timer stops immediately and resources are released.
Pitfalls
- Forgetting to call cancel: timers and propagation goroutines are not released. defer cancel right after getting it.
- Storing a context in a struct field: explicitly forbidden by the docs; pass it as an argument.
- Using context to pass function arguments: WithValue keys are not type-safe, values need assertions; arguments should travel as normal parameters.
- Passing a nil context:
WithCancel(nil),WithValue(nil, ...)panic. When unsure, passcontext.TODO(). - Using the parent context inside a goroutine instead of deriving: a goroutine should use a context derived with
context.WithCancel(parent), so the select inside can react when the parent cancels. - Handing the cancel function to other goroutines: cancel should be called by the function that created it;
defer cancel()is the standard pattern. Don’t pass it around.
Interview questions
What is a Context, and what problem does it solve?
Answer: the standard mechanism for carrying a deadline, a cancellation signal, and request-scoped data across API boundaries. It solves cancellation and timeout in concurrent programs: when several goroutines cooperate and a request fails or times out, there needs to be a chain that carries the “stop” signal to every relevant goroutine, instead of hand-maintaining a pile of channels or a shared flag.
What methods does the Context interface have?
Answer: four. Deadline() returns the deadline; Done() returns the cancellation channel; Err() returns the cancellation reason; Value(key) fetches request-scoped data.
What is the difference between Background and TODO?
Answer: functionally identical, both uncancelable empty contexts; the difference is semantics. Background is the real root; TODO is a placeholder when the context choice is undecided, a reminder to replace it later.
After a parent context is canceled, what happens to its children?
Answer: the children are canceled too; Done closes and Err returns the parent’s cancellation reason. The reverse does not happen: a child cancel does not affect the parent. Cancellation propagates one way, from root to leaves.
What happens if cancel is never called?
Answer: resource leak. The WithTimeout/WithDeadline timer keeps running and is not released until the timeout fires; with an uncancelable custom parent, the propagation goroutine also stays. The convention is defer cancel() right after obtaining it, so every return path releases.
What is the relationship between WithTimeout and WithDeadline?
Answer: WithTimeout(parent, d) is a wrapper around WithDeadline(parent, time.Now().Add(d)). Both are timerCtx underneath: one timer fires the cancellation. The only difference is the argument form: a duration versus a concrete time.
When does Err() return Canceled vs DeadlineExceeded?
Answer: an explicit cancel returns context.Canceled; timeout or deadline expiry returns context.DeadlineExceeded. Both are error values, distinguishable with errors.Is.
How is context value lookup implemented?
Answer: valueCtx stores a single key-value pair; Value() delegates to the parent context when not found at this level, walking up until the root or a hit. cancelCtx returns itself for the internal key (cancelCtxKey), used for cancellation propagation. Keys must be comparable and not built-in types, to avoid collisions.
How does a goroutine respond to cancellation?
Answer: use a derived context inside the goroutine, listen for <-ctx.Done() in a select, clean up and exit when the signal arrives (see doWork above). Combined with WithTimeout, you can also cap the goroutine with a timeout.
CPU-bound loops have no blocking point; poll between steps:
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
step()
}For blocking calls, prefer passing ctx to the library — http, database/sql, and net all listen to Done internally and return an error immediately on cancel:
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
db.QueryContext(ctx, ...)For legacy libraries that do not accept a ctx, wrap the call in a goroutine with a select fallback, but if the underlying call never returns, the goroutine leaks; switch to a ctx-aware library when possible.
Can a context be stored in a struct?
Answer: no. The official rule is that a context must be passed explicitly as an argument, first parameter, usually named ctx. Storing it in a struct breaks the propagation path of the cancellation signal, and static analysis tools cannot check whether the pass-through is complete.