Skip to content
gohacks.dev
Concurrency

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.

4 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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:

main.goGo
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))
}
Text
before: <nil> <nil>
Err:   context canceled
Cause: server shutting down
matches shutdown: true

ctx.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:

main.goGo
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))
}
Text
first reason
parent went away

The 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:

main.goGo
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))
}
Text
Err:   context deadline exceeded
Cause: payment gateway exceeded its 20ms budget

Err() 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:

main.goGo
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))
}
Text
stopped because: worker 1 failed: disk full

Worker 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.