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, message context canceled: someone called cancel, or a parent was cancelled.
  • context.DeadlineExceeded, message context 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.