Context for Cancellation

A context.Context is Go’s standard way to carry cancellation signals, timeouts, and small pieces of request-scoped data across API boundaries and between goroutines. Whenever a program needs to say “stop what you’re doing, the caller no longer needs the result” — an HTTP request that timed out, a user who navigated away, a shutdown signal arriving — a Context is how that message travels through the call stack. It is one of the most important tools for writing concurrent Go code that does not leak goroutines or waste work on results nobody will ever read.

Overview: How Context Works

The context package, part of the standard library, defines a small interface:

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

You almost never implement this interface yourself. Instead you build a tree of contexts using the package’s constructor functions, starting from a root:

  • context.Background() — the root of almost every context tree: no deadline, never cancelled, carries no values. Used in main, in tests, and at the top of request-handling code.
  • context.TODO() — behaves identically to Background(), but signals “this code should eventually take a real context; one hasn’t been threaded through here yet.” Prefer it as a placeholder while refactoring.

Each of the following functions takes a parent Context and returns a new, derived child Context, plus (except for WithValue) a cancel function:

  • context.WithCancel(parent) — returns a child context and a cancel function that, when called, cancels that context and every context derived from it.
  • context.WithTimeout(parent, duration) — like WithCancel, but also cancels automatically once duration elapses.
  • context.WithDeadline(parent, time) — like WithTimeout, but you supply an absolute time.Time instead of a duration.
  • context.WithValue(parent, key, value) — returns a child context that carries one extra key/value pair, readable with Value.

Contexts form a tree, not a flat list. Calling context.WithCancel(parent) registers the new child with its parent internally, so cancelling the parent cancels the child too, and the child’s children, and so on down the whole subtree. Cancelling a child, however, never affects its parent or its siblings — cancellation only flows downward. This is exactly the shape you want: if an HTTP handler’s top-level context is cancelled because the client disconnected, every downstream database query, RPC call, and sub-goroutine spawned for that request should stop, without affecting unrelated requests being served concurrently.

The Done() method returns a channel of type <-chan struct{}. Under the hood that channel starts open, and cancelling the context closes it — nothing is ever sent on it. This matters because closing a channel is a broadcast: every goroutine blocked in a select on ctx.Done() wakes up at the same instant, no matter how many goroutines are waiting. A context is only ever cancelled once; calling cancel a second time, or letting a timeout fire after you already cancelled manually, is a harmless no-op. Once the Done() channel is closed, Err() explains why: context.Canceled if something called cancel(), or context.DeadlineExceeded if a timeout or deadline elapsed first.

Internally, WithTimeout is implemented in terms of WithDeadline, which is implemented in terms of WithCancel plus a time.Timer that invokes the internal cancel function when it fires. That is why every With* constructor gives you back a cancel function — even WithTimeout, whose context will eventually cancel itself anyway. You must still call it, typically with defer cancel() right after creating the context. If you don’t, the timer and the bookkeeping entry the child keeps in its parent stay alive until the deadline actually fires, which can leak memory in a long-running server handling many short-lived contexts. go vet even has a dedicated check, lostcancel, that flags a discarded cancel function.

WithValue is different in kind: it doesn’t create a cancellation boundary at all, it just attaches one key/value pair that Value(key) can retrieve, walking up the parent chain until it finds a match (or reaches the root and returns nil). It exists for request-scoped metadata that must cross API boundaries you don’t control — a request ID for logging, an authenticated user, a tracing span — not as a way to sneak extra parameters into a function instead of adding a parameter. If a value is required for a function to do its job, it belongs in the function signature, not buried inside a context.

Finally, a Context is meant to be passed explicitly, never stored. The convention, enforced by nothing but consistently followed across the Go ecosystem, is that ctx is the first parameter of any function that needs one, and it is never stashed in a struct field.

Syntax

ctx, cancel := context.WithCancel(parentCtx)
ctx, cancel := context.WithTimeout(parentCtx, duration)
ctx, cancel := context.WithDeadline(parentCtx, deadlineTime)
ctx := context.WithValue(parentCtx, key, value)
defer cancel()
Piece Meaning
parentCtx An existing Context to derive from — context.Background() at the root, or a Context passed into your function.
ctx The new, derived Context. Pass this to any function or goroutine that should respect the same cancellation.
cancel A function with signature func(). Calling it cancels ctx and everything derived from it. Must always be called, usually via defer.
duration A time.Duration, e.g. 5*time.Second, after which ctx cancels itself.
key, value any values. The key should be an unexported custom type, never a plain string, to avoid collisions between packages.

Examples

Example 1: Cancelling a Context Manually

package main

import (
	"context"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	fmt.Println("before cancel, err:", ctx.Err())

	cancel()

	fmt.Println("after cancel, err:", ctx.Err())

	select {
	case <-ctx.Done():
		fmt.Println("done channel is closed")
	default:
		fmt.Println("done channel is still open")
	}
}

Output:

before cancel, err: <nil>
after cancel, err: context canceled
done channel is closed

Before cancel() is called, ctx.Err() is nil — the context is still active. Calling cancel() closes the internal Done() channel, so Err() immediately starts reporting context.Canceled, and a select that checks ctx.Done() against a default case takes the Done() branch instead of falling through, because receiving from a closed channel never blocks.

Example 2: A Timeout Racing Real Work

package main

import (
	"context"
	"fmt"
	"time"
)

func doWork(ctx context.Context, resultCh chan<- string) {
	select {
	case <-ctx.Done():
		resultCh <- "cancelled: " + ctx.Err().Error()
	case <-time.After(2 * time.Second):
		resultCh <- "work finished"
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	resultCh := make(chan string)
	go doWork(ctx, resultCh)

	fmt.Println(<-resultCh)
}

Output:

cancelled: context deadline exceeded

doWork races two channels in a select: the context’s Done() channel and a 2-second timer from time.After. Because the context was created with a 50-millisecond timeout, ctx.Done() always closes long before the 2-second timer fires, so the ctx.Done() branch always wins and reports context.DeadlineExceeded. This is the pattern real code uses to bound how long it will wait for a slow dependency: give the operation a context, and let whichever finishes first — the real work or the deadline — decide the outcome.

Example 3: Carrying a Value Through a Context

package main

import (
	"context"
	"fmt"
)

type ctxKey string

const requestIDKey ctxKey = "requestID"

func process(ctx context.Context) {
	if id, ok := ctx.Value(requestIDKey).(string); ok {
		fmt.Println("processing request:", id)
	} else {
		fmt.Println("no request id found")
	}
}

func main() {
	ctx := context.WithValue(context.Background(), requestIDKey, "abc-123")
	process(ctx)

	process(context.Background())
}

Output:

processing request: abc-123
no request id found

The key is a small unexported type, ctxKey, not a bare string — that guarantees no other package’s context key can accidentally collide with requestIDKey, even if that package also happens to use the string "requestID" as a key. Value returns any, so callers must use a type assertion (with the two-result form, to avoid panicking when the value is absent) before using it. When process is called with a plain context.Background(), the lookup fails and ok is false.

How It Works Step by Step

Tracing Example 2 shows the mechanics of a timeout in practice:

  1. context.WithTimeout creates a child of context.Background() and starts an internal 50-millisecond timer.
  2. main launches doWork in a new goroutine and immediately blocks trying to receive from resultCh.
  3. Inside doWork, the select statement blocks on two channel receives at once: ctx.Done() and time.After(2 * time.Second).
  4. After roughly 50 milliseconds, the context’s internal timer fires, which closes the Done() channel and sets ctx.Err() to context.DeadlineExceeded.
  5. The select in doWork wakes up on the now-ready ctx.Done() case (the 2-second timer never gets there) and sends a formatted string on resultCh.
  6. main‘s blocked receive on resultCh unblocks, and fmt.Println prints the result.

Cancellation propagation down a context tree works the same way, just without a timer involved:

package main

import (
	"context"
	"fmt"
)

func main() {
	parent, parentCancel := context.WithCancel(context.Background())
	child, childCancel := context.WithCancel(parent)
	defer childCancel()

	parentCancel()

	select {
	case <-child.Done():
		fmt.Println("child cancelled because:", child.Err())
	default:
		fmt.Println("child still active")
	}
}

Output:

child cancelled because: context canceled

When child was created from parent, it registered itself with parent so that it can be notified later. Calling parentCancel() walks that internal list of registered children and cancels each one, which is why child.Done() is already closed by the time the select runs, even though nothing ever called childCancel() directly.

Common Mistakes

Mistake 1: Discarding the cancel function

It’s tempting to throw away cancel when a context already has a timeout, reasoning that it will expire on its own eventually:

func fetch(url string) {
	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
	// cancel is discarded -- the timer and its bookkeeping
	// stay alive until the full 5 seconds pass, every call
	doRequest(ctx, url)
}

If fetch is called frequently and each request finishes in milliseconds, the discarded timers pile up and keep resources alive far longer than necessary. Always capture and call cancel, typically deferred right where the context is created:

func fetch(url string) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	doRequest(ctx, url)
}

Mistake 2: Storing a Context in a struct field

Stashing a context on a struct looks convenient, but it invites bugs where a stale or already-cancelled context from an earlier call gets reused for unrelated work:

type Server struct {
	ctx context.Context
}

func (s *Server) Handle() {
	// s.ctx might belong to a request that finished
	// long ago, or might not be set at all
	doWork(s.ctx)
}

Pass the context explicitly into every method that needs it instead, so each call carries its own, correctly-scoped context:

type Server struct {
	// no stored context
}

func (s *Server) Handle(ctx context.Context) {
	doWork(ctx)
}

Best Practices

  • Always call the cancel function returned by WithCancel, WithTimeout, or WithDeadline, typically via defer, even if you expect the context to expire on its own.
  • Make ctx the first parameter of any function that needs it, and never store a Context in a struct field.
  • Never pass a nil Context; use context.TODO() if you aren’t yet sure which context to thread through.
  • Use WithValue sparingly, only for request-scoped data that crosses API boundaries you don’t control (request IDs, auth info, tracing spans) — never as a substitute for an explicit function parameter.
  • Define context keys as an unexported custom type, not a bare string, to avoid collisions with keys from other packages.
  • Creating a context does nothing by itself — only code that actually selects on ctx.Done() or checks ctx.Err() will stop early when it’s cancelled.
  • Derive contexts through the call chain instead of calling context.Background() deep inside your code, or you lose the caller’s cancellation and values.
  • Set timeouts as close as possible to the outermost boundary that knows the real time budget, and let that budget flow down through derived contexts rather than each layer inventing its own.

Practice Exercises

  1. Write a function that simulates work with time.Sleep inside a goroutine, called with a context.WithTimeout of 100 milliseconds. Use a select to print whether the work finished first or the context timed out first, for a sleep of 10ms and again for a sleep of 500ms.
  2. Write a program that starts three goroutines, each looping and checking ctx.Done() in a select with a default case. Cancel the shared context from main after a short delay, and use a sync.WaitGroup to confirm all three goroutines actually exit.
  3. Build a context tree with one parent and two independently-created children (each from context.WithCancel(parent)). Cancel only one child, then print Err() for both children and the parent to confirm the sibling and the parent are unaffected.

Summary

  • context.Context carries cancellation signals, deadlines, and small request-scoped values across function and goroutine boundaries.
  • Contexts form a tree: WithCancel, WithTimeout, and WithDeadline each derive a child from a parent; cancelling a parent cancels its entire subtree, never the reverse.
  • Done() returns a channel that is closed, not sent to, on cancellation, so every goroutine selecting on it wakes at once; Err() then reports context.Canceled or context.DeadlineExceeded.
  • Always call the cancel function you get back from a With* constructor, usually via defer, to release resources immediately instead of waiting for a timer to fire.
  • Use WithValue only for request-scoped metadata crossing API boundaries, with an unexported key type — never as a substitute for explicit function parameters.
  • Pass ctx explicitly as the first parameter of functions that need it; never store it in a struct.