Context and Cancellation
Context and Cancellation
A context is how one function tells another “this work is still wanted.” The boring default is: the first argument is ctx context.Context, you check it, and you pass it down. When the desk closes, workers stop because the context says so — not because someone pulled the power cord.
Mental model
context.Background() is the empty root. Use it in main, in tests, and at the edge of a program. context.TODO() is a placeholder when you have not wired a real context yet. Do not leave TODO in production paths.
WithCancel returns a child context and a cancel function. Calling cancel() makes ctx.Done() ready. ctx.Err() becomes context.Canceled.
WithTimeout / WithDeadline cancel automatically when time runs out. ctx.Err() is then context.DeadlineExceeded. Always defer cancel() so the timer is released if you finish early.
WithCancelCause (and context.Cause) attach a real error (“kitchen closed”) instead of the generic canceled value.
Cancel is advisory. A worker that never looks at ctx will not stop. The next chapter is about not starting goroutines you cannot stop. This chapter is the stop signal itself.
Do not store a context inside a struct that outlives one request. Pass it as the first argument.
Worked examples
Case 1: Background, first argument
Save as ctx_print.go. The printer takes ctx first even though this call never cancels. The signature is the habit.
// ctx_print.go
package main
import (
"context"
"fmt"
)
func printTicket(ctx context.Context, id int) error {
if err := ctx.Err(); err != nil {
return err
}
fmt.Println("printed", id)
return nil
}
func main() {
ctx := context.Background()
if err := printTicket(ctx, 7); err != nil {
fmt.Println("err:", err)
}
}Run:
go run ctx_print.goOutput:
printed 7
Background is never canceled. ctx.Err() is nil. The check is still the right first line for anything that might later run under a timeout.
Case 2: WithCancel stops a worker
Save as cancel_worker.go. The worker loops on tickets until ctx.Done(). main cancels, then waits.
// cancel_worker.go
package main
import (
"context"
"fmt"
"sync"
)
func worker(ctx context.Context, tickets <-chan int) {
for {
select {
case <-ctx.Done():
fmt.Println("stopped:", ctx.Err())
return
case id, ok := <-tickets:
if !ok {
fmt.Println("queue closed")
return
}
fmt.Println("printed", id)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
tickets := make(chan int)
var wg sync.WaitGroup
wg.Go(func() { worker(ctx, tickets) })
cancel()
wg.Wait()
}Run:
go run cancel_worker.goOutput:
stopped: context canceled
tickets is never ready. Done is. The worker returns. Wait unblocks. If you omit cancel(), this program hangs. If you omit Wait, you are back to killing the worker by exiting main.
Case 3: WithTimeout
Save as timeout.go. The kitchen is slow. The desk only waits 20 milliseconds.
// timeout.go
package main
import (
"context"
"fmt"
"time"
)
func kitchen(ctx context.Context) error {
select {
case <-time.After(time.Second):
fmt.Println("order ready")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := kitchen(ctx)
fmt.Println("desk:", err)
}Run:
go run timeout.goOutput:
desk: context deadline exceeded
defer cancel() stops the timeout timer as soon as main returns. Even though the deadline already fired, calling cancel is still required — if the kitchen had finished in 1ms, you would leak the timer without it.
Case 4: WithCancelCause
Save as cause.go. The cancel reason is a desk error, not a generic string you parse later.
// cause.go
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(fmt.Errorf("kitchen closed"))
fmt.Println("err:", ctx.Err())
fmt.Println("cause:", context.Cause(ctx))
}Run:
go run cause.goOutput:
err: context canceled
cause: kitchen closed
Err stays context.Canceled. Cause is the error you passed. errors.Is(ctx.Err(), context.Canceled) is still true. Use Cause when a human or a log needs the why.
Case 5: Timeout plus a worker that checks
Save as prep.go. Same worker shape as Case 2, deadline as Case 3.
// prep.go
package main
import (
"context"
"fmt"
"sync"
"time"
)
func prep(ctx context.Context) {
select {
case <-time.After(time.Second):
fmt.Println("prep done")
case <-ctx.Done():
fmt.Println("prep stopped:", context.Cause(ctx))
}
}
func main() {
ctx, cancel := context.WithTimeoutCause(
context.Background(),
20*time.Millisecond,
fmt.Errorf("shift ended"),
)
defer cancel()
var wg sync.WaitGroup
wg.Go(func() { prep(ctx) })
wg.Wait()
}Run:
go run prep.goOutput:
prep stopped: shift ended
WithTimeoutCause is Go 1.21+. Cause is shift ended. Err would still be context.DeadlineExceeded. Pass the same ctx into every function that should stop together.
The trap
Save as ignore_ctx.go. The worker takes a context and never looks at it. Cancel does nothing. main waits on a channel that nobody receives from — hang — so we use a timeout on main to show the worker is still alive.
// ignore_ctx.go
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, out chan<- string) {
time.Sleep(50 * time.Millisecond)
out <- "still working"
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
out := make(chan string, 1)
go worker(ctx, out)
cancel()
select {
case msg := <-out:
fmt.Println(msg)
case <-time.After(200 * time.Millisecond):
fmt.Println("main gave up")
}
}Run:
go run ignore_ctx.goOutput:
still working
Cancel ran. The worker slept anyway and sent. That is a leak of work and, if out were unbuffered and main had moved on, a leaked goroutine blocked on send. Checking ctx is not decoration. The fix is Case 2: select on ctx.Done() and the real work.
The boring rule
- First argument:
ctx context.Context. - Create with
Backgroundat the edge; derive cancel/timeout children below. defer cancel()every time you get acancelfunction.- Workers
selectonctx.Done()(or returnctx.Err()at the top of a loop). - Prefer
WithCancelCause/WithTimeoutCausewhen the reason matters in logs. - Do not put optional parameters in
context.Value. A function argument is cheaper and typed. - Do not store
ctxon a long-lived struct.
Try this
- In
cancel_worker.go, send7on a bufferedticketschannel beforecancel(). Run it a few times. You may seeprinted 7thenstopped, or onlystopped. Then send after the worker is running and before cancel, withWaitstill at the end. - In
timeout.go, raise the timeout to2 * time.Second. Confirmorder readyanddesk: <nil>. - In
cause.go, printcontext.Cause(ctx)beforecancel. It should matchctx.Err()(niluntil cancel). - Fix
ignore_ctx.go: inworker,selecton<-ctx.Done()andtime.After(50 * time.Millisecond). Aftercancel(), you should print a stop path, notstill working.