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

```go
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:

```go
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

```go
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

```go
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

```go
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:

```go
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 `select`s 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

```go
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:

```go
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:

```go
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

```go
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):

```go
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.

```go
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:

```go
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:

```go
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:

```go
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.


---

> Author: Nite  
> URL: https://www.nite07.com/en/posts/go-context/  

