GAP·MAP

Go context

[ middle depth ]

What context.Context carries

A context.Context threads three things through a call chain: a cancellation signal, a deadline, and a small set of request-scoped values. It exists so every goroutine working on one request can be told to stop and can read the same deadline, without each layer inventing its own done channel.

Every tree starts at a root. context.Background() is the root for main, servers, and tests. context.TODO() behaves identically at runtime but signals to a reader that a real context has not been plumbed through yet. Both are never canceled, carry no deadline, and hold no values. Never pass a nil context; pass context.TODO() when you are unsure which to use.

Deriving a child with WithCancel, WithTimeout, WithDeadline

You do not mutate a context. You derive a child from a parent, and each derivation returns the child plus a CancelFunc:

// derive a child; each returns (child, cancel):
context.WithCancel(parent)                 // cancel manually
context.WithTimeout(parent, 2*time.Second) // cancel after a duration, or when it fires
context.WithDeadline(parent, deadline)     // cancel at a fixed time

Calling the CancelFunc cancels the child and everything derived from it, and releases the timer that WithTimeout or WithDeadline started. Skipping the call leaks the child and its timer until the parent is canceled, so defer cancel() goes on the line right after creation:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel() // required even if the timeout fires first

Stopping work with ctx.Done() and ctx.Err()

A worker watches ctx.Done(), a channel that closes on cancellation, then reads ctx.Err() to learn why:

select {
case <-ctx.Done():
    return ctx.Err() // context.Canceled or context.DeadlineExceeded
case v := <-work:
    handle(v)
}

A manual or parent cancel yields context.Canceled; a blown timeout yields context.DeadlineExceeded. A long loop that never checks ctx.Done() ignores cancellation and leaks its goroutine, because a goroutine is never garbage collected while it runs.

Passing request data with WithValue

WithValue carries request-scoped data such as a request id or auth token. Key it with an unexported custom type so two packages cannot collide, and read it with the comma-ok form because Value returns any:

type ctxKey int
const userIDKey ctxKey = 0

ctx := context.WithValue(parent, userIDKey, 42)
id, ok := ctx.Value(userIDKey).(int) // ok guards a missing or mistyped key

Wrong "I will pass the userID through context.Value so I do not have to change every signature." A required argument passed this way is invisible to the compiler and silently nil when a caller forgets it. Right make it an explicit parameter, and reserve context values for data that crosses API boundaries, such as the request id, auth token, or trace id.

[ senior depth ]

Cancellation propagates down the tree only

Cancelling a context closes the Done() channel of that context and every context derived from it. It never travels upward: cancelling a child leaves the parent and any siblings running. The Context interface has no Cancel method and Done() is receive-only, so a child structurally cannot cancel its parent. The CancelFunc returned at derivation is the only handle that cancels, and it belongs to whoever created the child.

parent, cancel := context.WithCancel(context.Background())
child := context.WithValue(parent, userIDKey, 42)
cancel()        // cancels parent and child together
<-child.Done()  // already closed

Background() and TODO() return a nil Done() channel and can never be canceled. A receive on a nil channel blocks forever, which is why a value-only context never fires a Done case in a select.

Why you must call cancel even after a timeout fires

A common belief is that once a WithTimeout deadline fires, cleanup is automatic. It is not. The timer and the parent's reference to the child stay alive until you call cancel. Failing to call it leaks the child and its children until the parent is canceled. go vet's lostcancel analyzer flags any control-flow path that drops the CancelFunc, so defer cancel() belongs immediately after creation regardless of how the context ends.

The CancelFunc itself is forgiving: safe to call from multiple goroutines, and every call after the first is a no-op that never panics. It also does not wait for the work to stop, and the Done channel may close asynchronously after cancel returns.

Canceled vs DeadlineExceeded, and context.Cause

ctx.Err() is nil while Done() is open, then returns context.Canceled for a manual or parent cancel and context.DeadlineExceeded for a fired timeout. Read it only after you have seen Done() close. When a parent cancel races a child's own timeout, whichever closes Done() first wins, so a WithTimeout child can still report Canceled.

context.DeadlineExceeded satisfies net.Error with Timeout() returning true; context.Canceled is a plain sentinel and does not.

ctx, cancel := context.WithCancelCause(context.Background()) // go1.20
cancel(errors.New("boom"))
fmt.Println(ctx.Err())          // context canceled
fmt.Println(context.Cause(ctx)) // boom

WithCancelCause records a reason, yet ctx.Err() still returns context.Canceled; only context.Cause(ctx) returns the specific error.

WithValue pitfalls and detaching with WithoutCancel

Each Value lookup walks the parent chain, an O(depth) linear scan, so deep WithValue towers cost more per read. A built-in key type such as string risks a cross-package collision; an unexported type cannot. Stored values are shared across goroutines, so anything you put in must be safe for concurrent use.

To spawn work that must outlive the request, context.WithoutCancel(parent) (go1.21.0) keeps the values but detaches from cancellation, so its Done() is nil. context.AfterFunc(ctx, f) (go1.21.0) runs f in its own goroutine once ctx is canceled, and returns a stop function that reports whether it prevented the run.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Go

was this useful?