Go Intermediate #5 context.Context in Depth

7 min read

As #4 select and Timeouts showed — concurrent code almost always involves cancellation and timeouts. Go’s standard tool is context.Context. This post covers every use of context.

Why context became the standard #

While a single request is processed — many goroutines and many service calls happen. Across all of them you need to:

  1. Propagate a cancellation signal
  2. Carry a deadline
  3. Pass request-scoped data (user ID, trace ID, etc.)

Hand-rolling done channels or deadline variables every time is tedious and inconsistent. context was added to Go’s standard library to standardize all three behind a single interface.

The Context interface #

context.Context
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

A very small interface with four methods. We mostly only care about three of them.

  • Done() — a channel that closes on cancellation or timeout
  • Err() — the reason after Done fires (context.Canceled or context.DeadlineExceeded)
  • Value(key) — fetch a stored value

Base contexts — Background and TODO #

base context
ctx := context.Background()    // empty root context — never canceled
ctx := context.TODO()           // not yet sure what to use — placeholder
  • Background — root context starting point for main, initialization, tests
  • TODO — a case where the right context to use is undecided (placeholder showing intent)

Both behave the same but differ in intent. Usually you use Background().

Creating child contexts #

You attach new behavior (cancellation, timeout, etc.) to an existing context to make a child context.

WithCancel — explicit cancellation #

WithCancel
ctx, cancel := context.WithCancel(context.Background())
defer cancel()    // prevent resource leaks

go work(ctx)

// at some point
cancel()    // ctx.Done() closes

Calling cancel() — closes ctx.Done()’s channel and every goroutine listening hears it.

defer cancel() is key — to clean up the child context when the function returns. Without it the context and tracked resources may not be GC’d.

WithTimeout — auto-cancel after a duration #

WithTimeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := slowOperation(ctx)

Must finish in 5 seconds — if not, ctx auto-cancels and slowOperation can exit early.

WithDeadline — cancel at an absolute time #

WithDeadline
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

An absolute time rather than a duration — same concept as timeout, different expression. Good for wall-clock “until midnight”-style cases.

Listening for cancellation #

listening with select
func work(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()    // context.Canceled or DeadlineExceeded
		default:
			// do one unit of work
			doOneStep()
		}
	}
}

Putting <-ctx.Done() as a case is the standard. On cancellation it exits immediately.

Long IO should also accept ctx #

propagating ctx into IO
func fetch(ctx context.Context, url string) ([]byte, error) {
	req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return io.ReadAll(resp.Body)
}

http.NewRequestWithContext takes ctx — if ctx is canceled while the request is in flight, the request is canceled too. Almost every IO API in the standard library has evolved to accept ctx.

Context propagation rule #

Take ctx as the first parameter, and pass it down to every nested call

propagation
func handleRequest(ctx context.Context, req Request) error {
	user, err := loadUser(ctx, req.UserID)         // propagate
	if err != nil {
		return err
	}

	posts, err := loadPosts(ctx, user.ID)            // propagate
	if err != nil {
		return err
	}

	return saveAudit(ctx, user, posts)               // propagate
}

Every function taking ctx context.Context as the first argument is the standard convention. When the caller cancels — every nested call automatically aborts.

Never store ctx in a struct #

anti-pattern
type Service struct {
	ctx context.Context    // ✗ no
}

ctx must propagate only as a function parameter. Stashing it in an object makes it hard to track which ctx is used where, and lifecycles get tangled.

Cases that break this rule are genuinely rare. The first-parameter pattern covers almost everything.

Storing values in context — WithValue #

A tool for passing request-scoped data (user, trace ID, etc.).

WithValue
type userKey struct{}

ctx := context.WithValue(parent, userKey{}, currentUser)

// elsewhere
user, ok := ctx.Value(userKey{}).(*User)
if !ok {
	// missing
}

The key is usually an empty value of a struct{} type. String keys risk collisions and are not recommended. Using your own package’s unexported type as the key is standard.

Wrapping in helpers #

key helpers
type contextKey int

const (
	userKey contextKey = iota
	requestIDKey
)

func WithUser(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, userKey, u)
}

func UserFrom(ctx context.Context) (*User, bool) {
	u, ok := ctx.Value(userKey).(*User)
	return u, ok
}

// usage
ctx = WithUser(ctx, user)
if u, ok := UserFrom(ctx); ok {
	// ...
}

Keys and types live in one place; everywhere else, only the helper functions are used. Both safety and readability improve.

Where WithValue fits vs doesn’t #

Fits #

  • Request ID, trace ID
  • Authenticated user info
  • Logging context (logger instance)

Metadata that almost every function during request processing wants to see.

Doesn’t fit #

  • Domain data that should be explicit function parameters
anti-pattern
ctx := context.WithValue(parent, "amount", 1000)
ctx := context.WithValue(parent, "userID", "u1")

// wrong call
processPayment(ctx)
recommended
processPayment(ctx, userID, amount)

Hiding what should be explicit parameters inside ctx means you can’t tell what the function needs from its signature, and debugging becomes hard.

Rule: ctx holds “horizontal” data, parameters hold “vertical” data. Horizontal is what every layer commonly sees; vertical is what’s directly relevant to that function’s role.

Common patterns #

1) HTTP handler #

HTTP handler
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()    // ctx tied to the request (cancels when client disconnects)

	user, err := loadUser(ctx, getUserID(r))
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	// ...
}

r.Context() — a context that auto-cancels when the HTTP request is dropped. When the client disconnects (closes the browser tab, etc.) the server-side work also stops.

2) Multi-concurrent requests + cancel all on first failure #

early cancel
ctx, cancel := context.WithCancel(parent)
defer cancel()

results := make(chan Result, n)
for _, url := range urls {
	go func(u string) {
		r, err := fetch(ctx, u)
		results <- Result{r, err}
	}(url)
}

for i := 0; i < n; i++ {
	r := <-results
	if r.err != nil {
		cancel()    // one fails — abort the rest
		return r.err
	}
}

This pattern is covered in a more polished form (errgroup) in #1 Concurrency Patterns.

3) Database queries #

DB queries also take ctx
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE active = $1", true)

The standard database/sql API has ctx-aware functions — QueryContext, ExecContext, BeginTx. Through ctx the query can be canceled too.

4) Kubernetes / cloud SDKs #

Most Go SDKs take ctx in every RPC call. When a user cancels a request, the upstream cloud API call is aborted too.

errgroup — context’s partner #

Often used together — covered in detail in #1 Concurrency Patterns, but a preview.

errgroup preview
import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(parent)

g.Go(func() error {
	return fetch1(ctx)
})

g.Go(func() error {
	return fetch2(ctx)
})

if err := g.Wait(); err != nil {
	return err
}

Launching many goroutines, canceling all on the first error, and collecting results — that whole pattern collapses into a single errgroup call. One of the most useful libraries for Go concurrency code.

Wrap-up #

What we covered:

  • context.Context — the standard tool for cancellation/timeout/request-scoped values
  • Background() is the root, TODO() is a placeholder
  • WithCancel / WithTimeout / WithDeadline for child contexts
  • Always defer cancel() — prevent resource leaks
  • <-ctx.Done() to listen for cancellation inside select
  • Pass ctx as the first function parameter — never store it in a struct
  • WithValue is for horizontal metadata only (domain data goes to parameters)
  • HTTP requests, DB queries, external APIs all integrate with ctx
  • A child context cancels when its parent does

In the next post (#6 Testing) we cover writing tests and benchmarks with the standard testing package, plus the table-driven test pattern.

X