Go context, fully explained — cancellation, time budgets, and four pitfalls
Contents10 sections
I was already taking ctx as the first argument and passing it along. What I had never checked was whether canceling actually stops the goroutine. After stalling on that question in an interview, I went back to context from the start. Cancellation only sends a signal to stop; the side that receives it is the one that actually stops.
Why it exists: ghost work
One incoming request fans out inside the server. It hits the database, calls an external API, reads files, and each of those may start more goroutines. Now suppose the client hangs up after three seconds.
With no way to tell anyone, the server keeps producing results nobody is waiting for. That’s ghost work. It goes unnoticed most of the time, but once an incident slows responses and clients start timing out, abandoned work piles up, eats every resource, and turns into a cascading failure. Context is the standard way to carry “you can stop now” through the whole call chain.
What it actually is turns out to be simple: an interface with four methods.
type Context interface { Deadline() (deadline time.Time, ok bool) // until when Done() <-chan struct{} // a channel that signals the end (by closing) Err() error // why it ended (Canceled / DeadlineExceeded) Value(key any) any // request-scoped values}Using it comes down to four constructors and two ways of reading.
| Create | When |
|---|---|
Background() |
The root of the tree. main, initialization, tests, and the top of each incoming request |
WithCancel |
When you want to cut it off yourself |
WithTimeout / WithDeadline |
When you want a time limit. The one you’ll use most in practice |
WithValue |
Only request-scoped metadata such as request IDs and traces (more below) |
| Read | What it tells you |
|---|---|
select + ctx.Done() |
Whether it has ended |
ctx.Err() |
Why it ended: Canceled or DeadlineExceeded |
The important part is that these build a tree. Each WithXxx takes a parent and returns a derived context, and cancellation flows only from top to bottom.
Being a tree brings two rules. First, canceling a child leaves the parent untouched. If the DB query hits its 1-second timeout, the request as a whole keeps going. Second, deadlines only get shorter. If the parent has three seconds and a child asks for ten, the real limit is still three. That you can’t extend it is the safety feature.
Cancellation is a request: the receiver stops
In an interview I was once asked whether canceling a context kills the goroutine, and I stalled. The answer is no.
Go has no way at all to forcibly stop a goroutine from outside. There’s no API to kill a thread and no interrupts. So cancel() only closes a channel to say “you can stop now,” and actually stopping is up to the receiver. The documentation is explicit that a CancelFunc does not wait for the work to stop.
✓A loop that checks Done()
for _, vm := range vms { select { case <-ctx.Done(): return ctx.Err() default: } copyDisk(ctx, vm)}It exits on the next iteration.
✕A loop that ignores Done()
for _, vm := range vms { copyDisk(vm)}The signal arrives but nobody checks, so it runs to the end. That’s ghost work.
When someone asks why canceling didn’t stop anything, the answer is usually the loop that ignores Done(). That makes where you put the check the real design decision. The rule of thumb: right before anything that takes a while.
func migrate(ctx context.Context, vms []VM) error { for _, vm := range vms { select { case <-ctx.Done(): return ctx.Err() // Canceled or DeadlineExceeded default: // with default, this never blocks } if err := copyDisk(ctx, vm); err != nil { // pass it down as well return err } } return nil}Fortunately, the standard library handles its side. APIs that take a ctx, such as net/http and database/sql, watch Done() internally, stop when it fires, and return an error. The checks you have to add yourself are in your own long loops and CPU-heavy work.
If a server passes ctx everywhere and the load still doesn’t drop after cancellation, there’s probably a stretch of the chain that never looks at Done(). When that happens, I start by looking for the long-running loops.
Why Done() works as a broadcast
This raised a question for me. With a hundred goroutines, how does one cancellation reach all hundred at once? Send a value on a channel and only one receiver gets it.
The answer is that nothing is sent. Context closes the channel. Receiving from a closed channel immediately returns the zero value to every waiter, and they all wake up. A single channel can notify any number of goroutines.
This also explains why Done() returns <-chan struct{}. Because it’s receive-only, the receiving side can’t close it by accident (that’s a compile error), and struct{} takes zero bytes, so the signal itself costs nothing.
That property gives select two natural patterns. Pick whichever fits.
// ① Non-blocking check: in the middle of a compute loop, just ask "canceled yet?"select {case <-ctx.Done(): return ctx.Err()default: // with default, this doesn't block}
// ② Blocking wait: take whichever comes first, the result or the cancellationselect {case res := <-resultCh: return res, nilcase <-ctx.Done(): return nil, ctx.Err() // keeps you from blocking forever on a result}Time budgets: deadlines only get shorter
This was the last piece of context to click for me. I had thought of WithTimeout as “cut it off after a few seconds,” but it actually assigns a time budget to the whole request and makes the chain share it.
A derived context’s deadline can never be later than its parent’s. If the parent ends in three seconds and the child asks for ten, the limit is still three. The further down you go, the less time is left.
The budget even crosses process boundaries. gRPC sends the deadline in the request metadata, and the receiving server’s ctx gets the same deadline. Pass that ctx on to the next call and the entire chain of services shares one budget. Set three seconds at the front and the limit follows all the way to the database at the back.
parent, cancel := context.WithTimeout(context.Background(), 3*time.Second)defer cancel()
child, cancel2 := context.WithTimeout(parent, 10*time.Second) // pointless: the real limit is 3sdefer cancel2()// You can check the remaining time yourself before heavy workif dl, ok := ctx.Deadline(); ok && time.Until(dl) < 500*time.Millisecond { return ErrNotEnoughTime // if it can't finish anyway, don't start}func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // canceled when the client's connection closes if err := doWork(ctx); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) }}Pitfall ①: skip cancel and it leaks
I wrote defer cancel() by rote for a long time without knowing exactly what leaks if you don’t. The documentation answers briefly. Calling cancel does three things:
- It cancels the child and its children.
- It removes the parent’s reference to the child.
- It stops any associated timers.
Skip it and none of that happens. In the documentation’s words, the child leaks until the parent is canceled or the timer fires. If you derived it with WithCancel from a long-lived parent, that moment is when the server shuts down. You won’t notice it on one request; you will at thousands per second.
What if the timeout has already passed? When the timer fires, the context cancels itself and drops the parent’s reference too, so cancel has almost nothing left to do. The real problem is work that finishes early. If it’s done in two seconds and you never call cancel, the timer and the reference stay around for the remaining second. Either way, defer cancel() right after creating the context means you never have to think about it.
🧪 Try it
go vet catches a missing cancel
I deliberately wrote code that calls cancel on only one branch and ran go vet.
func leak(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) if ctx == nil { cancel() return }}$ go vet ./..../leak.go:9:2: the cancel function is not used on all paths (possible context leak)./leak.go:14:1: this return statement may be reached without using the cancel var defined on line 9It checks that cancel is called on every path and even points at the return that skips it. Put go vet in CI and nobody has to remember this one.
Pitfall ②: cancellation takes your cleanup down too
I only learned this one by living through it. A job failed and kicked off a rollback, and the rollback was being passed the same ctx. That ctx was already canceled. The rollback was canceled the moment it started, the snapshot it was supposed to delete stayed in storage, and the bill kept running. The error log only said context canceled, which made it hard to see that the real failure was the cleanup.
In hindsight it’s obvious. A cancellation means “stop all work related to this request,” and cleanup is part of that work. So cleanup has to be explicitly detached from the cancellation signal.
✕Clean up with the same ctx
func rollback(ctx context.Context, id string) { deleteSnapshot(ctx, id) // context canceled}The cleanup dies as soon as it starts, and resources you meant to delete keep costing money.
✓Detach it and set a fresh limit
func rollback(ctx context.Context, id string) { cctx := context.WithoutCancel(ctx) cctx, cancel := context.WithTimeout(cctx, 30*time.Second) defer cancel() deleteSnapshot(cctx, id)}The cleanup always runs, and it can’t hang forever either (Go 1.21+).
WithoutCancel keeps the values and cuts only the cancellation. The nice part is that the request ID and trace data survive, so the cleanup still logs with them. But the deadline goes away too, so you must set a new timeout. Otherwise you get the opposite problem: a cleanup that never ends.
Pitfalls ③④: overusing WithValue and storing ctx in structs
The other two are about using context where it doesn’t belong. The documentation spells out the rules clearly, so when in doubt, follow them.
| Rule | Why |
|---|---|
| Don’t store it in a struct | A ctx is tied to the lifetime of one call, not of an object. Stored in a field, nobody can tell when it will be canceled |
First parameter, named ctx |
It’s the documented convention, so both readers and linters expect it there |
| Never pass nil | If you don’t know what to pass, pass context.TODO(). It marks a spot to fix later |
| Value is for request-scoped data only | It is not a way to pass optional function parameters |
| Use an unexported type for keys | Basic types like string collide with keys from other packages |
The fourth row goes wrong most often in practice. WithValue looks convenient, but once required parameters start hiding in it, the signature no longer tells you what a function needs, and type safety is gone. A missing value still compiles, and the type assertion that reads it panics at run time.
// Bad: a required parameter hidden in ctx. The signature tells you nothingfunc createOrder(ctx context.Context) error { userID := ctx.Value("userID").(string) // panics at run time if missing // ...}
// Good: required values as arguments, only request metadata in ctxfunc createOrder(ctx context.Context, userID string) error { /* ... */ }
// Keys must be an unexported typetype traceIDKey struct{} // no other package can construct this type
func WithTraceID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, traceIDKey{}, id)}func TraceIDFrom(ctx context.Context) (string, bool) { // ship a type-safe accessor with it id, ok := ctx.Value(traceIDKey{}).(string) return id, ok}
// A method argument, not a struct fieldtype Server struct{ ctx context.Context } // badfunc (s *Server) Handle(ctx context.Context) error // goodNewer APIs worth knowing
The context package has quietly kept growing. In particular, not knowing why something was canceled got fixed in 1.20, and I’d been staring at meaningless context canceled logs without knowing it.
WithCancelCause,Cause(1.20): attach a reason to a cancellation. Debugging gets much easier.WithoutCancel(1.21): detach work that must finish even after cancellation, like cleanup and audit logs.AfterFunc(1.21): run a function when the ctx ends, without starting your own goroutine to watchDone().WithTimeoutCause,WithDeadlineCause(1.21): attach a reason to timeouts too, so you can tell which stage blew the budget.
ctx, cancel := context.WithCancelCause(parent)cancel(fmt.Errorf("upstream returned repeated 5xx: %w", err)) // err is the last 502
fmt.Println(ctx.Err(), "|", context.Cause(ctx))context canceled | upstream returned repeated 5xx: 502Err() always says context canceled, which was never much help. The information I actually needed lives in Cause().
When any one of several tasks failing should cancel all of them, errgroup wraps the pattern for you.
g, gctx := errgroup.WithContext(ctx)g.Go(func() error { return fetchUser(gctx) })g.Go(func() error { return fetchOrders(gctx) })if err := g.Wait(); err != nil { // the first error cancels gctx automatically return err}To tell errors apart, use the approach from part 3. The cause may be wrapped, so use errors.Is instead of ==.
if errors.Is(err, context.DeadlineExceeded) { return status(504) // timed out}if errors.Is(err, context.Canceled) { return // the client already left; no need to respond}Summary
- Go can’t kill a goroutine from outside. Long loops get their own
Done()checks. Done()closes a channel instead of sending a value, so every waiter wakes at once.- The time budget set at the front follows the chain to the end, and with gRPC it crosses process boundaries.
- Skip
canceland child contexts and timers stay attached to the parent, so writedefer cancel()right after creating one.go vetcatches the misses. - Detach cleanup with
WithoutCanceland give it its own timeout.
After five parts
Looking back, the pitfalls in each part had the same shape: an interface value being two slots, a slice being a window onto an array, what defer and closures hold on to and when, embedding’s promotion never routing calls back out, and cancellation being a signal rather than a command. Every time, I fell in by misjudging what the thing actually was. The wrap-up post folds all of this along two axes.
Go doesn’t hide the underlying reality for convenience. That felt unfriendly at first, but now it seems like a design that pulls most of the learning curve up front.
References
- context package docs (
CancelFunc,WithValuekey rules, not storing contexts in structs) - Go Concurrency Patterns: Context (Go blog)
- Go 1.20 release notes (
WithCancelCause) · Go 1.21 release notes (WithoutCancel,AfterFunc,WithTimeoutCause) - errgroup package docs
- gRPC: Deadlines (deadline propagation)
- The lostcancel check (
go vet)