Go Context Internals

Contents

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) or context.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 returns

Core properties of cancellation:

  • Calling cancel closes the context’s Done channel; Err() returns context.Canceled afterwards.
  • 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 exceeded

Running this prints context deadline exceeded.

  • WithTimeout(parent, d) is a thin wrapper around WithDeadline(parent, time.Now().Add(d)).
  • WithDeadline(parent, t) cancels automatically when the time comes; Err() returns context.DeadlineExceeded.
  • A manual cancel takes effect early; Err() returns context.Canceled, not DeadlineExceeded.
  • When the parent’s deadline is earlier than the new one, WithDeadline degenerates into WithCancel(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 WithValue panic.
  • 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:

  • done is created lazily: if nobody calls Done(), no channel is allocated. It is created on the first call to Done().
  • 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:

  1. If err is already non-nil, return immediately; that is the idempotency.
  2. Write err and cause.
  3. Close the done channel. If nobody ever called Done(), done is nil; store a package-level shared closed channel (closedchan) instead, saving one allocation.
  4. Iterate children and cancel each child context.
  5. Set children to nil.
  6. 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, parentCancelCtx sees 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 explicit cancel() goes through cancel(true, ...), and removeChild detaches 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 on Err(), and Cause() 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:

  • WithDeadline starts the timer with time.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. So defer 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 cancelCtxKey hit is special-cased: cancelCtx returns itself, timerCtx returns its embedded cancelCtx. This is how parentCancelCtx sees 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 via Cause(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, pass context.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.

Edit this page

Contents