Context: Cancellation That Propagates

The context package — how you cancel and time-bound concurrent work. Roots with Background, WithCancel and WithTimeout, ctx.Done and ctx.Err, why cancelling a parent cancels every child, ctx.Value used sparingly, and the always-defer-cancel rule. Compiled and run against Go 1.26.5.

You have started goroutines. Now you need to stop them. A request the caller abandoned, a query that has run past its deadline, a whole tree of workers that should wind down the instant one of them fails — every one of these is the same problem: how does a signal to quit reach code that is already running somewhere else? Go’s answer is the context package. A context.Context is a value you thread through every function in a call tree, and it carries one crucial thing: a channel that closes when the work should stop. Cancellation in Go is not a special signal or an interrupt. It is a closed channel that everyone downstream is watching.

This is why nearly every blocking function in the standard library — every database call, every HTTP request, every network read — takes a context.Context as its first argument. Passing one is how you keep the power to call the whole thing off. Everything here is compiled and run against Go 1.26.5.

Roots, and deriving from them

Every context tree starts at a root. context.Background() is the empty root you use in main, in tests, and at the top of a request — it is never cancelled and carries no values, just a starting point. context.TODO() is the same empty context, used as a placeholder when you haven’t yet decided which context belongs there; it signals “a context should go here” to readers and to static analysis. You rarely do anything with a root directly. Instead you derive a child from it, and the derivation is where cancellation gets attached.

context.WithCancel(parent) returns two things: a new context derived from the parent, and a cancel function. Calling cancel closes the new context’s Done() channel. That’s the entire mechanism — you hand the context to your workers, and you keep the cancel function to pull the trigger.

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
			return
		default:
			time.Sleep(10 * time.Millisecond)
		}
	}
}

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

	time.Sleep(50 * time.Millisecond)
	cancel() // tell the worker to stop
	time.Sleep(20 * time.Millisecond)
	fmt.Println("main done")
}
worker 1 stopping: context canceled
main done

The worker loops, and on every pass it selects on <-ctx.Done(). While the context is live that channel is open, so the default branch runs and the worker keeps busy. The moment main calls cancel(), the Done() channel closes, the receive becomes ready, and the worker takes the ctx.Done() branch and returns. This is the shape of nearly all cooperative cancellation in Go: a long-running goroutine watches ctx.Done() in a select, and cleanly exits when it fires. Cancellation is cooperative — nothing forcibly kills the goroutine; you have to check the channel for the signal to have any effect.

Deadlines: cancellation on a timer

Often you don’t want to cancel by hand — you want work to cancel itself if it runs too long. context.WithTimeout(parent, d) derives a context that cancels automatically after duration d; context.WithDeadline(parent, t) does the same for an absolute time. Both still hand back a cancel function, and you still call it.

When a context is done, ctx.Err() tells you why — and the two reasons are distinct values you can branch on. A context that hit its deadline reports context.DeadlineExceeded; one that was cancelled by hand reports context.Canceled.

// A context that times out.
tctx, tcancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer tcancel()
<-tctx.Done()
fmt.Println("timeout ctx.Err():", tctx.Err())

// A context we cancel by hand.
cctx, ccancel := context.WithCancel(context.Background())
ccancel()
<-cctx.Done()
fmt.Println("cancelled ctx.Err():", cctx.Err())
timeout ctx.Err(): context deadline exceeded
cancelled ctx.Err(): context canceled

Two different terminations, two different errors. Before the context is done, ctx.Err() returns nil; after, it returns whichever of these applies and keeps returning it. These are ordinary sentinel errors, so you match them with errors.Is(ctx.Err(), context.DeadlineExceeded) the same way you match any wrapped error — which matters because a failing standard-library call often returns the context’s error wrapped in its own, and errors.Is is how you tell “the deadline blew” apart from a real I/O fault.

The deadline is inherited too. A child derived from a context that already has a timeout can only shorten it, never extend it — a WithTimeout(parent, time.Hour) on a parent with ten seconds left still fires in ten seconds, because the child’s Done() closes as soon as either its own timer or the parent’s does. Deadlines only ever get tighter as you descend the tree, which is exactly the behavior you want: an inner call cannot outlive the outer budget it was given.

Cancellation flows downward

Here is the property that makes context worth threading everywhere: cancelling a parent cancels every context derived from it, all the way down. Build a tree — a parent, two children derived from it, a grandchild derived from one child — cancel only the parent, and the whole subtree goes with it.

parent, cancel := context.WithCancel(context.Background())
childA, cancelA := context.WithCancel(parent)
childB, cancelB := context.WithCancel(parent)
grandchild, cancelG := context.WithCancel(childA)
defer cancelA()
defer cancelB()
defer cancelG()

fmt.Println("before cancel, grandchild done?", isDone(grandchild))
cancel() // cancel the parent only
fmt.Println("after cancel:")
fmt.Println("  childA done?    ", isDone(childA))
fmt.Println("  childB done?    ", isDone(childB))
fmt.Println("  grandchild done?", isDone(grandchild))
fmt.Println("  grandchild err: ", grandchild.Err())
before cancel, grandchild done? false
after cancel:
  childA done?     true
  childB done?     true
  grandchild done? true
  grandchild err:  context canceled

One cancel() call on the parent, and both children and the grandchild are now done, each reporting context.Canceled. Nobody cancelled them individually. This is what lets you shut down an entire request’s worth of goroutines with a single call at the top: derive every worker’s context from the request context, and cancelling that one root — because the client disconnected, or an overall timeout fired — propagates to every corner of the tree at once. The signal only flows one way, downward from parent to child; cancelling a child leaves the parent untouched.

ctx.Value: for request data, used sparingly

A context can also carry values. context.WithValue(parent, key, val) derives a context that returns val from ctx.Value(key), and children inherit it. This is meant for request-scoped data that crosses API boundaries without belonging in every function signature: a request ID for tracing, an authenticated user, a deadline already baked in.

type ctxKey string

const requestIDKey ctxKey = "requestID"

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

func main() {
	ctx := context.WithValue(context.Background(), requestIDKey, "req-7f3a")
	handle(ctx)
	handle(context.Background())
}
handling request req-7f3a
no request id

Note the key. It is an unexported named type (ctxKey), not a bare string. That is not optional decoration: Value looks keys up by equality, and if two packages both used the string "requestID" they would collide and read each other’s data. A private key type makes collisions impossible, because no other package can construct your key. Use ctx.Value sparingly — it is an untyped, compiler-unchecked bag, and threading real dependencies through it instead of through explicit parameters makes code hard to follow. Reserve it for data that genuinely belongs to the request rather than to the function.

Always defer cancel()

One rule ties the package together: whenever you derive a context, call its cancel function — the idiom is defer cancel() on the line after you derive it. This holds even for WithTimeout, where the context will eventually cancel itself. The cancel function releases resources the context holds — a timer, an entry in the parent’s child list — and letting it go uncollected is a real leak. It leaks so reliably that go vet flags a discarded cancel function as an error, so a forgotten cancel fails your build rather than lurking. Derive, then immediately defer cancel(), and you never have to remember.

Cancelling with a reason: WithCancelCause

Plain cancellation tells a worker that it should stop, but not why. ctx.Err() only ever reports context.Canceled or context.DeadlineExceeded — it cannot say “the upstream request failed” or “we ran out of budget.” Go 1.20 closed that gap with context.WithCancelCause. It works exactly like WithCancel, except the cancel function takes an error, and context.Cause(ctx) reads it back.

var errBudgetSpent = errors.New("budget spent")

ctx, cancel := context.WithCancelCause(context.Background())
cancel(errBudgetSpent)

<-ctx.Done()
fmt.Println("ctx.Err():", ctx.Err())
fmt.Println("Cause:    ", context.Cause(ctx))
fmt.Println("is budget?", errors.Is(context.Cause(ctx), errBudgetSpent))
ctx.Err():   context canceled
Cause:       budget spent
is budget?   true

The key thing to see is that the two channels of information stay separate. ctx.Err() still reports context.Canceled — nothing about the context’s standard contract changed, so existing code that branches on Canceled versus DeadlineExceeded keeps working untouched. Your custom reason rides alongside on context.Cause(ctx), and because it is an ordinary error you match it with errors.Is like any other. This is how you thread a real failure down a call tree: cancel the root with the underlying error, and every worker that notices ctx.Done() can call context.Cause to learn what actually went wrong instead of just seeing a bare “canceled.” Pass nil to the cancel function and Cause falls back to ctx.Err(), so it is always safe to read.

Running cleanup when a context ends: AfterFunc

You often want a piece of code to run the moment a context is done, without dedicating a goroutine to sit in a select on ctx.Done(). Go 1.21’s context.AfterFunc(ctx, f) does exactly that: it registers f to run in its own goroutine as soon as ctx is cancelled or times out.

ctx, cancel := context.WithCancel(context.Background())

done := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
	fmt.Println("AfterFunc: context is done, running cleanup")
	close(done)
})
defer stop()

fmt.Println("cancelling...")
cancel()

<-done
fmt.Println("cleanup finished")
cancelling...
AfterFunc: context is done, running cleanup
cleanup finished

The instant cancel() fires, the registered function runs on its own goroutine. AfterFunc returns a stop function you can call to deregister f if you no longer need it — stop() returns true if it stopped the call before it started, false if the function had already been triggered (or already stopped). It is the tidy way to attach teardown to a context: closing a connection, releasing a resource, cancelling a downstream request. Note that f runs in a fresh goroutine and does not hold any lock, so if it touches shared state it still needs its own synchronization.

One related helper rounds out the modern set: context.WithoutCancel(parent) (also Go 1.21) derives a context that keeps the parent’s values but is immune to its cancellation — useful when you must finish a cleanup or an audit write even though the request that triggered it has already been cancelled.

Final thoughts

context is how Go carries a cancellation signal across goroutine boundaries: a Context holds a Done() channel that closes when work should stop, and downstream code watches that channel in a select. Start from Background() or TODO(), derive with WithCancel for manual cancellation or WithTimeout/WithDeadline for automatic, and read ctx.Err() to learn why it stopped — context.Canceled for a manual cancel, context.DeadlineExceeded for a timeout, both verified above. Cancelling a parent propagates to every derived child at once, which is the whole reason to thread one root through a call tree. Use ctx.Value only for request-scoped data, always with a private key type, and always defer cancel(). Cancellation isn’t magic — it’s a closed channel and the discipline to watch for it.

Next: pipelines: stages joined by channels — composing goroutines into stages where each one’s output channel is the next one’s input.

Comments