Context Cause and WithCancelCause Added in Go 1.20
Use context.WithCancelCause and context.Cause from Go 1.20 to record why a context ended, with timeout causes from Go 1.21 and a fan-out pattern.

A context canceled error tells you the context ended and nothing about who ended it or why. In a service where a cancellation can come from the client leaving, a shutdown signal, a failed sibling goroutine or a handler that returned, that is the hardest thing to debug. Go 1.20 added a small API for recording the reason, and it takes five lines to adopt.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This note shows the API, how it behaves with parents and timeouts, and a pattern for using it. It follows context explained and the diagnosis page context canceled. Everything ran on Go 1.25.6.
The API
context.WithCancelCause is WithCancel with an error on the cancel function. context.Cause reads it back:
package main
import (
"context"
"errors"
"fmt"
)
var errShutdown = errors.New("server shutting down")
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
fmt.Println("before:", ctx.Err(), context.Cause(ctx))
cancel(errShutdown)
fmt.Println("Err: ", ctx.Err())
fmt.Println("Cause:", context.Cause(ctx))
fmt.Println("matches shutdown:", errors.Is(context.Cause(ctx), errShutdown))
}before: <nil> <nil>
Err: context canceled
Cause: server shutting down
matches shutdown: truectx.Err() still returns context.Canceled, so nothing that depends on it changes, while context.Cause(ctx) returns the error passed to cancel. Cause values are ordinary errors, so you can wrap them, compare them with errors.Is and give them types that carry data.
First one wins
A context records one cancellation. After it has ended, further calls do not change the cause, and if the parent is cancelled first the child's cause is the parent's:
package main
import (
"context"
"errors"
"fmt"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("first reason"))
cancel(errors.New("second reason is ignored"))
fmt.Println(context.Cause(ctx))
parent, cancelParent := context.WithCancelCause(context.Background())
child, cancelChild := context.WithCancelCause(parent)
defer cancelChild(nil)
cancelParent(errors.New("parent went away"))
fmt.Println(context.Cause(child))
}first reason
parent went awayThe second cancel is a no-op, and the child reports the parent's cause because the parent cancelled it. Calling the cancel function with nil sets the cause to context.Canceled, which is what to do in a defer that only releases resources and has no reason to record.
Timeouts with causes
Go 1.21 added the same for deadlines:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeoutCause(context.Background(), 20*time.Millisecond,
errors.New("payment gateway exceeded its 20ms budget"))
defer cancel()
<-ctx.Done()
fmt.Println("Err: ", ctx.Err())
fmt.Println("Cause:", context.Cause(ctx))
}Err: context deadline exceeded
Cause: payment gateway exceeded its 20ms budgetErr() is context deadline exceeded, which tells you a deadline fired. Cause() says which one. In a handler with several nested timeouts, giving each a named cause turns an anonymous deadline error into a specific one, with no change to the code that checks Err.
A pattern for fan-out work
When several goroutines share a context and any failure should stop the rest, WithCancelCause records which failure came first, which errgroup also does for you but without the context's view:
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
var wg sync.WaitGroup
for i := range 3 {
wg.Go(func() {
select {
case <-time.After(time.Duration(i+1) * 20 * time.Millisecond):
if i == 1 {
cancel(fmt.Errorf("worker %d failed: %w", i, errors.New("disk full")))
}
case <-ctx.Done():
}
})
}
wg.Wait()
fmt.Println("stopped because:", context.Cause(ctx))
}stopped because: worker 1 failed: disk fullWorker 1 fails at 40 ms and cancels with a descriptive error; worker 2 sees the cancellation and stops; worker 0 had already finished. The shared context then answers the question "why did this stop?" with the failing worker's error and not just context canceled. When logging the end of a unit of work, print Cause whenever it differs from Err.
Rules of thumb
Use a cause at every call site of cancel that you control, naming the site: cancel(errors.New("auth middleware rejected token")). Log context.Cause(ctx) where you log a context error. Don't build logic on cause values across package boundaries unless you export them; for control flow, Err() remains the contract. The API is documented in the context package, and the proposal behind it is linked from the Go 1.20 release notes.


