48. `context`: cancellation and deadlines
A quit channel stops one goroutine. A context.Context stops an entire tree
of them — every function your request called, and everything they called —
with one signal. It's the standard way Go programs handle cancellation,
timeouts and request-scoped values.
The shape of it
A Context carries three things: a Done() channel that closes when it's
time to stop, an Err() explaining why, and optional values.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
fmt.Println("waiting...")
<-ctx.Done()
fmt.Println("ctx.Done() fired")
fmt.Println("reason:", ctx.Err())
}
context.Background() is the empty root context — you start from it in
main, and in a server the framework hands you one per request.
WithCancel derives a child context plus a cancel function. Calling
cancel() closes ctx.Done(), which anything selecting on it sees
immediately.
Cancelling a worker
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, wg *sync.WaitGroup, out chan<- int) {
defer wg.Done()
processed := 0
for {
select {
case <-ctx.Done():
fmt.Println("worker: stopping because", ctx.Err())
out <- processed
return
default:
processed++
time.Sleep(5 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
out := make(chan int, 1)
var wg sync.WaitGroup
wg.Add(1)
go worker(ctx, &wg, out)
time.Sleep(30 * time.Millisecond)
cancel()
wg.Wait()
fmt.Println("worker did some units of work:", <-out > 0)
}
The for { select { case <-ctx.Done(): return; default: ... } } loop is the
shape of every cancellable Go worker. The default case does one unit of
work and comes back to check again.
Timeouts and deadlines
The most common use by far:
package main
import (
"context"
"fmt"
"time"
)
func fetch(ctx context.Context, d time.Duration) (string, error) {
done := make(chan string, 1)
go func() {
time.Sleep(d)
done <- "data"
}()
select {
case v := <-done:
return v, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
fast, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
v, err := fetch(fast, 10*time.Millisecond)
fmt.Println("fast:", v, err)
slow, cancel2 := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel2()
v, err = fetch(slow, 200*time.Millisecond)
fmt.Println("slow:", v, err)
}
context.DeadlineExceeded is the error you get on timeout; context.Canceled
is what an explicit cancel() produces. Callers usually check with
errors.Is(err, context.DeadlineExceeded).
WithDeadline is the same thing with an absolute time instead of a duration.
Always defer cancel(), even when the context has a timeout. It releases
the timer and the child's resources immediately; skipping it is a slow leak,
and go vet will point it out.
Contexts form a tree
A derived context inherits its parent's cancellation. Cancel a parent and every descendant stops:
package main
import (
"context"
"fmt"
"time"
)
func main() {
parent, cancelParent := context.WithCancel(context.Background())
childA, cancelA := context.WithCancel(parent)
childB, cancelB := context.WithTimeout(parent, time.Hour)
defer cancelA()
defer cancelB()
go func() {
time.Sleep(20 * time.Millisecond)
cancelParent()
}()
<-childA.Done()
<-childB.Done()
fmt.Println("childA:", childA.Err())
fmt.Println("childB:", childB.Err())
fmt.Println("both stopped when the parent did")
}
childB had a one-hour timeout and still stopped in 20 ms. Cancellation
flows downward only: cancelling a child never affects its parent.
This is what makes context worth the plumbing. An HTTP handler gets a context tied to the client connection; if the client disconnects, every database query and outbound call started under it is cancelled automatically, all the way down.
Passing a context correctly
The conventions are strict, and Go tooling enforces most of them:
// Yes: first parameter, always named ctx
func FetchUser(ctx context.Context, id int) (*User, error)
// No: never store a context in a struct
type Client struct {
ctx context.Context // don't
}
// No: never pass nil
FetchUser(nil, 7) // use context.TODO() if you truly have none yet
- First parameter, named
ctx, typedcontext.Context. No exceptions. - Don't store it in a struct. A context belongs to one call, not to an object with a longer life.
context.TODO()marks a spot where a context should be threaded through but you haven't done it yet. It behaves likeBackground()and reads as a to-do to the next person.- Don't pass
nil.
Values (use sparingly)
A context can carry request-scoped values — a request ID, a trace span, an authenticated user:
package main
import (
"context"
"fmt"
)
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)
return
}
fmt.Println("handling request with no id")
}
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "abc-123")
handle(ctx)
handle(context.Background())
}
Two rules that keep this from becoming a mess:
- Use an unexported custom key type (
type ctxKey string), never a bare string. It makes collisions between packages impossible. - Only for request-scoped metadata that crosses API boundaries. Not for optional arguments, not for dependencies. A value that's needed for the function to work belongs in the signature, where the compiler can check it.
Context values are untyped and invisible to the compiler — every read is a type assertion that might fail. That's why the advice is "sparingly".
Where you'll meet it
Almost every I/O-shaped API in modern Go takes a context:
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
rows, err := db.QueryContext(ctx, "SELECT ...")
err := conn.PingContext(ctx)
Which means: thread a context through your own functions and you get end-to-end timeout and cancellation across your whole stack, for free. That's the payoff for the plumbing.
Your turn
Give slowTask (200 ms) a 50 ms context timeout and print the resulting
error:
context deadline exceeded
package main
import (
"context"
"fmt"
"time"
)
func slowTask(ctx context.Context) error {
select {
case <-time.After(200 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
// create a 50ms timeout context, defer its cancel, call slowTask, print the error
}
package main
import (
"context"
"fmt"
"time"
)
func slowTask(ctx context.Context) error {
select {
case <-time.After(200 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if err := slowTask(ctx); err != nil {
fmt.Println(err)
}
}
That's concurrency. Next module: writing one function that works for many types — generics.