Checking Why a Context Ended in Go
Tell context.Canceled from context.DeadlineExceeded with errors.Is, attach your own reason with WithCancelCause and context.Cause, and run cleanup with AfterFunc.
When a context ends, your code often needs to react differently depending on the reason. A user who closed the tab is not an error worth paging anyone about. A database that took too long might be. This page shows how to find out why a context ended.
Canceled vs DeadlineExceeded
ctx.Err() returns one of two values once the context is done:
context.Canceled, messagecontext canceled: someone calledcancel, or a parent was cancelled.context.DeadlineExceeded, messagecontext deadline exceeded: the deadline passed.
Errors you get back from functions are usually wrapped, so check them with errors.Is, not ==:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func work(ctx context.Context) error {
<-ctx.Done()
return fmt.Errorf("load report: %w", ctx.Err())
}
func describe(err error) string {
switch {
case errors.Is(err, context.Canceled):
return "caller gave up, do not log as failure"
case errors.Is(err, context.DeadlineExceeded):
return "too slow, worth logging"
}
return "other error"
}
func main() {
ctx1, cancel1 := context.WithCancel(context.Background())
cancel1()
fmt.Println(describe(work(ctx1)))
ctx2, cancel2 := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel2()
fmt.Println(describe(work(ctx2)))
}
Output:
caller gave up, do not log as failure
too slow, worth logging
Do not alert on context canceled:
In HTTP servers, context canceled mostly means the client disconnected. Counting it as a server error fills dashboards with noise. Filter it out with errors.Is(err, context.Canceled) before logging at error level.
Adding Your Own Reason With WithCancelCause
context canceled does not say who cancelled or why. Since Go 1.20, context.WithCancelCause lets you attach an error when cancelling, and context.Cause(ctx) reads it back:
package main
import (
"context"
"errors"
"fmt"
)
var ErrShutdown = errors.New("server shutting down")
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(ErrShutdown)
fmt.Println("Err: ", ctx.Err())
fmt.Println("Cause:", context.Cause(ctx))
fmt.Println("is shutdown:", errors.Is(context.Cause(ctx), ErrShutdown))
}
Output:
Err: context canceled
Cause: server shutting down
is shutdown: true
ctx.Err() stays context canceled so existing code keeps working, while context.Cause carries the detail. Go 1.21 added WithTimeoutCause and WithDeadlineCause for the same idea with time limits.
Cause on a normal context:
For a context that was not created with a cause function, context.Cause(ctx) simply returns the same value as ctx.Err(). It is safe to call on any context.
Running Cleanup When a Context Ends
context.AfterFunc (Go 1.21) runs a function in its own goroutine once the context is done. It is useful for closing something that does not understand contexts:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
cleaned := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
fmt.Println("closing connection:", ctx.Err())
close(cleaned)
})
defer stop()
<-cleaned
}
This prints closing connection: context deadline exceeded. Calling stop() before the context ends cancels the scheduled cleanup.
Checking Err before Done is closed:
ctx.Err() returns nil until the context is actually done. Code like if ctx.Err() == context.Canceled right after starting work will be false, not because nothing will cancel, but because nothing has yet. Check after the work returns, or wait on ctx.Done().
Clear cancellation reasons:
If your logs can tell "client went away", "we timed out" and "we shut down" apart, your context handling gives you everything you need when debugging.
Summary
ctx.Err() is context.Canceled or context.DeadlineExceeded once a context ends, and wrapped errors should be checked with errors.Is. Treat cancellation by the caller as normal, not as a failure. WithCancelCause and context.Cause attach and read a specific reason, and context.AfterFunc runs cleanup when the context finishes.