Learn Go Series (#15) - context: Cancellation, Deadlines, and Request-Scoped Values
Learn Go Series (#15) - context: Cancellation, Deadlines, and Request-Scoped Values
What will I learn
- What
context.Contextis and the problem it solves: cancelling and deadlining a whole tree of goroutines; context.Background,WithCancel, and thectx.Done()channel every cancellable operation listens to;WithTimeoutandWithDeadline, and why you alwaysdefer cancel();- Writing a loop or a call chain that stops promptly when its context is cancelled;
WithValuefor request-scoped data, and how to do it without abusing it;- The universal convention:
ctx context.Contextis the first parameter, always.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
- Episodes 13-14 (goroutines, channels, select) especially;
- The ambition to learn Go programming.
Difficulty
- Intermediate
Curriculum (of the Learn Go Series):
- Learn Go Series (#1) - Introduction to Go
- Learn Go Series (#2) - Variables, Types, Constants, and iota
- Learn Go Series (#3) - Functions and First-Class Functions
- Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop
- Learn Go Series (#5) - Arrays, Slices, and Slice Internals
- Learn Go Series (#6) - Maps and Sets
- Learn Go Series (#7) - Strings, Runes, and Unicode
- Learn Go Series (#8) - Structs, Methods, and Receivers
- Learn Go Series (#9) - Interfaces and Implicit Satisfaction
- Learn Go Series (#10) - Pointers, Memory, and Escape Analysis
- Learn Go Series (#11) - Error Handling the Go Way
- Learn Go Series (#12) - Packages, Modules, and Project Layout
- Learn Go Series (#13) - Goroutines and Channels: the Fundamentals
- Learn Go Series (#14) - select and the sync Toolbox
- Learn Go Series (#15) - context: Cancellation, Deadlines, and Request-Scoped Values (this post)
Learn Go Series (#15) - context: Cancellation, Deadlines, and Request-Scoped Values
You have goroutines running work. Now the user closes the browser tab, or a request exceeds its time budget, or one part of a job fails and the rest is pointless -- how do you tell all those goroutines to stop, promptly and cleanly? That is what context is for. A context.Context flows down through a call tree, and when it is cancelled -- explicitly, or because a deadline passed -- every goroutine watching it finds out and can bail out. Every serious Go server, client, and database call takes a context as its first argument, so this is not optional knowledge.
Here is the thread that ties this episode back to the last two. In episode 13 we ended on the humble chan struct{} done channel -- a zero-byte channel you close once to broadcast "stop" to every receiver at once -- and I promised then that this was the seed of cancellation. In episode 14 we raced operations against time.After for one-shot timeouts and I flagged that context was the real-world version of that trick. Well, here we are. context is not a new concurrency primitive; it is those two ideas -- a closable broadcast channel and a deadline -- wrapped in a small, standard interface that the whole ecosystem agrees to pass around. Once you see that ctx.Done() is just a channel you select on, the mystery evaporates and what remains is a convention: thread one value through your call tree, and one cancel at the top stops everything below it. First, last episode's exercises.
Solutions to Episode 14 Exercises
Exercise 1 -- a timeout wrapper. A buffered result channel so the goroutine never leaks when the timeout wins:
package main
import (
"errors"
"fmt"
"time"
)
func fetch(delay, timeout time.Duration) (string, error) {
result := make(chan string, 1) // buffered: the goroutine can send even if we gave up
go func() {
time.Sleep(delay)
result <- "data"
}()
select {
case r := <-result:
return r, nil
case <-time.After(timeout):
return "", errors.New("timed out")
}
}
func main() {
r, err := fetch(20*time.Millisecond, 100*time.Millisecond)
fmt.Println(r, err) // data
_, err = fetch(100*time.Millisecond, 20*time.Millisecond)
fmt.Println(err) // timed out
}
Exercise 2 -- a counter, two ways:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
mCount := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
mCount++
mu.Unlock()
}()
}
wg.Wait()
var aCount atomic.Int64
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
aCount.Add(1)
}()
}
wg.Wait()
fmt.Println(mCount, aCount.Load()) // 1000 1000
// For a single counter, the atomic is simpler and faster than a mutex.
}
Exercise 3 -- fan-in with select and the nil-channel trick:
package main
import "fmt"
func gen(nums ...int) <-chan int {
ch := make(chan int)
go func() {
for _, n := range nums {
ch <- n
}
close(ch)
}()
return ch
}
func main() {
a := gen(1, 2, 3)
b := gen(10, 20)
sum := 0
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // a nil channel case is never selected again
continue
}
sum += v
case v, ok := <-b:
if !ok {
b = nil
continue
}
sum += v
}
}
fmt.Println("sum:", sum) // 36
}
Now, context.
The Done channel and WithCancel
A context.Context carries a cancellation signal via its Done() method, which returns a channel that closes when the context is cancelled. You start from context.Background() (the empty root context) and derive a cancellable child with context.WithCancel, which gives you back the context and a cancel function to trigger it:
package main
import (
"context"
"fmt"
)
func worker(ctx context.Context, done chan<- string) {
<-ctx.Done() // blocks until the context is cancelled
done <- "worker stopped: " + ctx.Err().Error()
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan string)
go worker(ctx, done)
cancel() // signal cancellation to everyone holding ctx
fmt.Println(<-done) // worker stopped: context canceled
}
Calling cancel() closes ctx.Done(), which unblocks the worker's <-ctx.Done(). After cancellation, ctx.Err() tells you why -- context.Canceled here. This is the whole mechanism: a closable channel plus an error, wrapped in a value you pass down. Because closing a channel broadcasts to all receivers, one cancel() stops any number of goroutines watching the same context.
A couple of details in that tiny program deserve slowing down on, because they are the shape of every context you will ever write. First, context.Background() is the root -- an empty, never-cancelled context you start from at the top of a program (main, an incoming request, a test). You do not create contexts out of thin air; you derive them, always from an existing parent, and Background is where the chain begins. Second, the cancel function is idempotent and cheap: calling it a second time does nothing, so you never have to guard it, which is exactly why the defer cancel() habit we are about to lean on is safe even when you have already cancelled explicitly. And third -- the one that trips people up -- cancellation flows downward only. Cancelling a child does not touch the parent, but cancelling a parent cancels every context derived from it, recursively. That directionality is the whole point: you hand a derived context to a sub-task, and if you cancel it, only that sub-task and its descendants stop, while the rest of the tree carries on. It is a proper tree, not a flat broadcast, and that structure is what makes context scale from one goroutine to a whole request graph.
Deadlines with WithTimeout
More often than manual cancellation, you want a time budget: this operation gets 50 milliseconds, then give up. context.WithTimeout derives a context that cancels itself after a duration (WithDeadline takes an absolute time instead). You still get a cancel function, and you defer cancel() to release the timer promptly even if you finish early:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel() // release resources; harmless if already cancelled
select {
case <-time.After(200 * time.Millisecond): // pretend work takes 200ms
fmt.Println("work finished")
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err()) // context deadline exceeded
}
}
The 50ms deadline fires before the 200ms work, so ctx.Done() wins and ctx.Err() is context.DeadlineExceeded. The defer cancel() is not optional politeness -- an un-cancelled timeout context leaks a timer until it fires, and go vet will warn you if you forget it. Always defer cancel() right after WithTimeout/WithCancel.
The distinction between WithTimeout and WithDeadline is worth a sentence, because people reach for the wrong one out of habit. WithTimeout(parent, d) is "cancel after this duration from now" -- 50 milliseconds, 5 seconds, whatever. WithDeadline(parent, t) is "cancel at this absolute time" -- a time.Time. Internally the first is literally implemented as the second (WithTimeout just computes now + d and calls WithDeadline), so they are the same machinery with two different front doors. Use WithTimeout for the common case ("this call gets 2 seconds") and WithDeadline when the moment is fixed by something outside your control -- an SLA that says "respond before 12:00:00 sharp", or a deadline you received from a caller and want to propagate downstream without shrinking it. And here is the elegant part of that propagation: when you derive a child timeout from a parent that already has a deadline, the child gets whichever deadline is earlier. You can tighten a budget as you go deeper, but you can never loosen it -- a sub-call cannot grant itself more time than its parent had. That single rule is what makes a per-request timeout at the top of an HTTP handler actually bound every database query and outbound call underneath it, without any of them having to know the top-level number.
A promptly-cancellable loop
For a context to actually stop your work, your work has to check it. A long loop selects on ctx.Done() alongside its real job, so it returns quickly when cancelled instead of running to completion. This cooperative checking is the pattern behind every cancellable operation:
package main
import (
"context"
"fmt"
"time"
)
func count(ctx context.Context) int {
n := 0
for {
select {
case <-ctx.Done():
return n // stop as soon as we are cancelled
default:
n++
time.Sleep(time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
fmt.Println("counted about", count(ctx), "before the deadline")
}
count loops until the context's deadline (~20ms) closes Done(), then returns. The exact number varies run to run -- the point is that it stops promptly rather than ignoring the deadline. A goroutine that never checks its context cannot be cancelled, which is the number-one context mistake: passing a context around dutifully but never selecting on ctx.Done().
I want to hammer on this because it is the single biggest misconception about context, and it catches experienced people too. Cancellation in Go is cooperative, not preemptive. There is no magic that reaches into a running goroutine and kills it; context cannot force anything to stop. All a cancelled context does is close a channel -- the actual stopping is your code choosing to notice. So the whole game is making sure your long-running work has a place where it looks up and asks "am I still wanted?". In a tight compute loop that means a select with a default (check ctx.Done(), and if nothing is there, do one more chunk of work); in an I/O-bound loop it usually means passing ctx straight into the call you are already making, because the well-behaved standard-library and third-party functions -- http.Request, database drivers, net dials -- already select on it for you and return ctx.Err() the instant it fires. The failure mode to burn into memory is the diligent-but-useless one: a function that accepts a ctx context.Context, threads it politely to everything it calls, and then sits in a for loop that never once mentions ctx.Done(). It looks cancellable -- it has the parameter, it passes the review -- but it will happily run to completion long after everyone stopped caring about its answer. The parameter is a promise; the select is you keeping it.
Request-scoped values
A context can also carry a few request-scoped values down the call tree -- a request ID, a trace span, an authenticated user -- with context.WithValue. Use a private, custom key type to avoid collisions with other packages' keys, and read values back with a type assertion:
package main
import (
"context"
"fmt"
)
type ctxKey string // a private key type prevents collisions across packages
const requestIDKey ctxKey = "requestID"
func handle(ctx context.Context) {
if id, ok := ctx.Value(requestIDKey).(string); ok {
fmt.Println("handling request", id)
}
}
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "req-42")
handle(ctx)
}
WithValue returns a child context carrying the pair; ctx.Value(key) retrieves it as an any, which you type-assert back. The strong guidance here is restraint: context values are for data that genuinely travels with the request across API boundaries (IDs, auth, tracing), not for passing normal function parameters. If a function needs a value to do its job, make it an argument. Overusing WithValue turns your context into an untyped grab-bag that hides a function's real dependencies.
The private-key-type trick is the part that looks like ceremony but genuinely matters, so let me justify it. Notice I did not write context.WithValue(ctx, "requestID", ...) with a plain string key. If two packages both used the string "requestID", they would collide -- one would silently overwrite or read the other's value, and you would spend an afternoon debugging a value that is mysteriously the wrong type. By defining type ctxKey string (an unexported named type) and using a constant of that type as the key, the key is unique to my package: no other package can even construct a value of my private ctxKey type, so no other package can accidentally read or clobber my slot. The lookup is by type and value, so ctxKey("requestID") and the plain string("requestID") are different keys entirely. That is the idiom the standard library itself uses. And the reason for all the restraint on top of the safety: because ctx.Value returns any, every read is an untyped lookup with a type assertion, which means the compiler cannot help you -- a typo in the key or a wrong type assertion fails silently or at runtime, not at compile time. Having said that, a real function parameter is checked by the compiler, is visible in the signature, and documents the dependency. So the rule of thumb writes itself: if a value is a genuine input to what the function computes, it belongs in the parameter list where the compiler guards it; only truly cross-cutting, request-scoped metadata -- the request ID, the trace span, the authenticated user -- earns a place in the context.
Propagation, and the first-parameter convention
The power of context is that it propagates: pass the same ctx down through every function and goroutine, and one cancellation at the top stops the whole tree. The universal convention -- followed by the entire standard library -- is that ctx is the first parameter, named ctx, and you never store a context in a struct; you pass it through the call chain:
package main
import (
"context"
"fmt"
"time"
)
// ctx is always the first parameter, by convention.
func step(ctx context.Context, name string) error {
select {
case <-time.After(10 * time.Millisecond):
fmt.Println("completed", name)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func pipeline(ctx context.Context) error {
if err := step(ctx, "one"); err != nil {
return err // a cancelled context short-circuits the rest
}
return step(ctx, "two")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if err := pipeline(ctx); err != nil {
fmt.Println("pipeline failed:", err)
} else {
fmt.Println("pipeline ok")
}
}
Both steps finish inside the 100ms budget, so the pipeline succeeds; tighten the timeout below 20ms and the second (or first) step returns ctx.Err() and the pipeline short-circuits. Every step threads the same ctx, so cancelling at the top would stop the chain wherever it happened to be. When you meet net/http and database calls later in the series, you will see this exact ctx-first shape everywhere -- it is the connective tissue of real Go programs.
Two conventions in that snippet are not stylistic preferences, they are near-universal rules the entire Go community follows, and breaking them will get your code review comments. The first: ctx is the first parameter, named ctx, full stop. Not the second, not the last, not stuffed into an options struct -- first. It reads func Do(ctx context.Context, args...) everywhere in the standard library and everywhere in idiomatic code, and the consistency is the feature: any reader knows instantly whether a function participates in cancellation just by glancing at its first argument. The second rule is the one beginners most want to violate: never store a Context in a struct. It is so tempting -- "I will just save it as a field so I do not have to pass it around" -- and it is wrong, because a context represents the lifetime of one operation, and a struct usually outlives any single operation. Stash a context in a field and you have frozen one request's deadline (and one request's cancellation) into an object that other requests will reuse, which is a subtle, horrible bug. The context is passed through the call chain, method by method, so that each call gets the current operation's context, never a stale one. The (rare, documented) exceptions exist, but as a learner you should treat "context in a struct field" as simply not allowed. Pass it, do not park it.
The same idea in Python
Python's asyncio has cancellation via Task.cancel() and timeouts via asyncio.wait_for / asyncio.timeout; threads have no clean built-in cancellation at all (you cannot safely kill a thread), so people pass an Event to signal stop -- much like Go's Done() channel:
import asyncio
async def work():
await asyncio.sleep(2) # simulate a slow task
return "done"
async def main():
try:
result = await asyncio.wait_for(work(), timeout=0.05)
print(result)
except asyncio.TimeoutError:
print("timed out") # like ctx.Err() == DeadlineExceeded
asyncio.run(main())
Python's asyncio.wait_for mirrors WithTimeout, and a manual threading.Event mirrors WithCancel's Done() channel. Go unifies both under one Context value that also carries deadlines and request data, threaded as the first argument through everything -- one consistent mechanism the whole ecosystem agrees on.
The comparison is not a knock on Python, it is a diffrence in where the coordination lives. In the threading world you assemble cancellation by hand: create a threading.Event, pass it to every worker, and have each worker check event.is_set() at the points where it is safe to stop -- which is, note, exactly the same cooperative check-a-flag pattern Go's ctx.Done() gives you, just spelled out yourself and without the deadline machinery bolted on. In asyncio you get Task.cancel() and asyncio.timeout, which are closer, but the model is different again (a CancelledError is raised into the coroutine at its next await, which is more preemptive-feeling than Go's "close a channel and hope someone checks"). Go's bet is to collapse all of it -- manual cancellation, deadlines, absolute deadlines, and request-scoped values -- into a single Context type that every function takes as its first argument, so the same value carries the stop signal, the time budget, and the request metadata down one consistent path. You do not choose between an Event and a wait_for and a thread-local; you pass ctx, and it is all of them at once. That uniformity is the reason a Go database driver, HTTP client, and your own business logic can all be cancelled by one cancel() at the top without ever having agreed on anything but the context interface.
Exercises
Cancel a worker on the first result. Launch three goroutines that each sleep a different random-ish fixed duration and then send their name. Use
context.WithCancel, and as soon asmainreceives the first result, callcancel(). Have each worker also select onctx.Done()so the losers stop early instead of finishing.A deadline that is met, and one that is missed. Write
func process(ctx context.Context, work time.Duration) errorthat selects ontime.After(work)versusctx.Done(). Call it once with a generousWithTimeout(work completes) and once with a tight one (deadline exceeded), and print both outcomes.Thread a request ID. Put a request ID into a context with
WithValue(using a private key type), then call two nested functions that both read and log the ID from the context. Confirm the value survives being passed down the chain.
What we learned
context.Contextcarries a cancellation signal down a call tree;ctx.Done()returns a channel that closes on cancellation, andctx.Err()says why (CanceledorDeadlineExceeded);- Derive contexts from
context.Background():WithCancelfor manual cancellation,WithTimeout/WithDeadlinefor a time budget -- and alwaysdefer cancel()to avoid leaking a timer; - A goroutine is only cancellable if it checks its context -- select on
ctx.Done()alongside the real work so it stops promptly; WithValuecarries request-scoped data (IDs, auth, tracing) with a private key type -- use it sparingly, never for ordinary function parameters;- Context propagates: pass the same
ctxeverywhere and one cancellation stops the whole tree; - The convention is universal --
ctx context.Contextis the first parameter, and you pass it through rather than storing it in a struct.
Next episode we cover the biggest addition to the language in years: generics -- type parameters, constraints, and inference -- so you can write one function or data structure that works for many types without giving up type safety. See you there.
De groeten, en happy hacking! ;-)