Go Context: Principles, Patterns, and Internal Mechanics
The context package became part of Go’s standadr library in Go 1.7 after being used extensively inside Google. It provides a unified way to coordinate cancellation, deadlines, and request-scoped values across API boundaries and goroutines.
Motivation
Common scenarios where context is essential:
- Set timeouts for operations that depend on external systems (databases, RPCs, HTTP services) so slow dependencies don’t stall your service.
- Propagate request-scoped metadata (e.g., trace IDs, auth tokens) to downstream calls for diagnostics and observability.
The Context API
A Context represents request-scoped data and cancellation signals. It is defined by the interface:
type Context interface {
Deadline() (time.Time, bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
- Deadline: reports the time at which work should be canceled. If none is set, the boolean is false.
- Done: a channel that is closed when the context is canceled or its deadline exceeds. If no cancellation applies, it may be nil.
- Err: explains why the context finished: nil if still active, context.Canceled if canceled, or context.DeadlineExceeded if the deadline passed.
- Value: retrieves request-scoped data by key.
Creating Contexts
Root contexts:
- context.Background(): the root for incoming requests and main functions.
- context.TODO(): a placeholder when you don’t yet know which context to use.
Derived contexts:
- context.WithCancel(parent)
- context.WithDeadline(parent, t)
- context.WithTimeout(parent, d)
- context.WithValue(parent, key, value)
WithCancel, WithDeadline, and WithTimeout return a child context and a CancelFunc. Invoking the CancelFunc cancels that context and all of its descendanst.
Practical Usage
Time-bounded database query
package main
import (
"context"
"database/sql"
"log"
"time"
)
func fetchProduct(ctx context.Context, db *sql.DB, id int64) (*sql.Rows, error) {
// Derive a context with a timeout for this specific operation
cctx, stop := context.WithTimeout(ctx, 1200*time.Millisecond)
defer stop()
// QueryContext honors ctx cancellation and deadlines
return db.QueryContext(cctx, "SELECT * FROM products WHERE id = ?", id)
}
func main() {
var db *sql.DB // assume initialized
rows, err := fetchProduct(context.Background(), db, 42)
if err != nil {
log.Println("query failed:", err)
return
}
defer rows.Close()
// process rows
}
The database/sql package cooperates with Context by checking Done before or during blocking operations. A simplified pattern looks like:
// Pseudo-logic for acquiring a pooled connection respecting ctx
select {
case <-ctx.Done():
return nil, ctx.Err()
case conn := <-poolAcquire:
return conn, nil
}
And prior to executing a driver operation:
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
// proceed with the driver call
}
If the timeout elapses or the context is canceled, Done is closed and the operation short-circuits with Err.
Request-scoped metadata and trace propagation
package tracing
import (
"context"
"net/http"
)
// Unexported key type prevents collisions with other packages
type requestIDKey struct{}
var ridKey requestIDKey
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ridKey, id)
}
func RequestID(ctx context.Context) (string, bool) {
v := ctx.Value(ridKey)
s, ok := v.(string)
return s, ok
}
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = generateID() // implement as needed
}
ctx := WithRequestID(r.Context(), id)
// Apply a per-request timeout
cctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
next.ServeHTTP(w, r.WithContext(cctx))
})
}
Downstream functions can extract the request ID and attach it to logs or headers:
func callRPC(ctx context.Context, payload any) error {
if id, ok := RequestID(ctx); ok {
// include id in logs/headers/metrics
_ = id
}
// make RPC using an API that accepts ctx
return nil
}
How It Works Internally (Conceptual)
Empty contexts
context.Background() and context.TODO() return immutable, empty contexts that never cancel, have no deadline, and return nil from Value for all keys. They satisfy Context with trivial implementations.
Cancellation tree
Cancellation contexts form a tree: canceling a parent cancels all descendants. A simplified shape of a cancelable node:
// Illustrative, simplified version
type cancelNode struct {
parent context.Context
mu sync.Mutex
doneCh chan struct{} // closed on cancel
children map[*cancelNode]struct{}
why error // context.Canceled or context.DeadlineExceeded
}
func newCancelNode(p context.Context) *cancelNode {
return &cancelNode{parent: p}
}
func (n *cancelNode) Done() <-chan struct{} {
n.mu.Lock()
if n.doneCh == nil {
n.doneCh = make(chan struct{})
}
ch := n.doneCh
n.mu.Unlock()
return ch
}
func (n *cancelNode) Err() error {
n.mu.Lock()
e := n.why
n.mu.Unlock()
return e
}
func (n *cancelNode) cancel(err error) {
n.mu.Lock()
if n.why != nil {
n.mu.Unlock()
return // already canceled
}
n.why = err
if n.doneCh == nil {
// represent closed
ch := make(chan struct{})
close(ch)
n.doneCh = ch
} else {
close(n.doneCh)
}
kids := n.children
n.children = nil
n.mu.Unlock()
for c := range kids {
c.cancel(err)
}
}
Wiring a child to its parent requires either registering the child in the parent’s children map (if the parent is a cancelable type) or falling back to a goroutine that waits on parent.Done and cancels the child accordingly:
func linkParentChild(parent context.Context, child *cancelNode) {
if p, ok := asCancelNode(parent); ok {
p.mu.Lock()
if p.why != nil { // parent already canceled
child.cancel(p.why)
} else {
if p.children == nil {
p.children = make(map[*cancelNode]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
return
}
go func() {
select {
case <-parent.Done():
child.cancel(parent.Err())
case <-child.Done():
}
}()
}
context.WithCancel is essentially building such a node and returning a Context backed by it along with a function that calls cancel(context.Canceled).
Deadlines and timeouts
Deadlines are built on top of cancellation. A timer-based context holds a time.Timer and a deadline:
// Illustrative, simplified version
type timerNode struct {
*cancelNode
timer *time.Timer
deadline time.Time
}
func withDeadline(p context.Context, d time.Time) (*timerNode, context.CancelFunc) {
// if parent’s deadline is earlier, a plain WithCancel is sufficient
if pd, ok := p.Deadline(); ok && pd.Before(d) {
cn := newCancelNode(p)
linkParentChild(p, cn)
return &timerNode{cancelNode: cn, deadline: pd}, func() { cn.cancel(context.Canceled) }
}
cn := newCancelNode(p)
tn := &timerNode{cancelNode: cn, deadline: d}
linkParentChild(p, cn)
delay := time.Until(d)
if delay <= 0 {
cn.cancel(context.DeadlineExceeded)
return tn, func() { cn.cancel(context.Canceled) }
}
cn.mu.Lock()
if cn.why == nil {
tn.timer = time.AfterFunc(delay, func() {
cn.cancel(context.DeadlineExceeded)
})
}
cn.mu.Unlock()
return tn, func() {
cn.cancel(context.Canceled)
cn.mu.Lock()
if tn.timer != nil {
tn.timer.Stop()
tn.timer = nil
}
cn.mu.Unlock()
}
}
context.WithTimeout is a convenience wrapper around WithDeadline using now+duration.
Value propagation
Value contexts store a single key-value pair and delegate to the parent on lookup. A simplified illustration:
// Illustrative, simplified version
type kvCtx struct {
parent context.Context
key any
val any
}
func (c *kvCtx) Deadline() (time.Time, bool) { return c.parent.Deadline() }
func (c *kvCtx) Done() <-chan struct{} { return c.parent.Done() }
func (c *kvCtx) Err() error { return c.parent.Err() }
func (c *kvCtx) Value(k any) any {
if c.key == k {
return c.val
}
return c.parent.Value(k)
}
context.WithValue enforces that keys are comparable and non-nil. Lookups walk up the chain until a matching key is found.
Guidelines and Caveats
- Always pass a non-nil Context. Use context.TODO as a placeholder if needed.
- Derive per-request contexts from context.Background at process boundaries (e.g., when an HTTP request arrives).
- Cancel what you create. If you call WithCancel/WithTimeout/WithDeadline, ensure the returned cancel function is called.
- Propagate ctx through your call graph: the first parameter should generally be ctx context.Context.
- Use Value only for request-scoped metadata that’s ubiquitous across layers (IDs, auth tokens). Define an unexported key type to avoid collisions.
- Don’t store large objects in Context; they live for the duration of the request and increase memory pressure.
- Don’t put mutable or optional business parameters in Context; pass them explicitly as function arguments.
- Contexts are safe for concurrent use, but do not store contexts in structs for long-lived reuse; derive fresh children for each logical operation.
- Prefer WithTimeout/WithDeadline to bound remote calls; check ctx.Done in loops and long-running tasks to exit promptly.
- Keys must be comparable; avoid basic types like string for keys across package boundaries by using distinct unexported types.