Errors and Context Cancellation in Go Programs Explained
How context.Canceled and context.DeadlineExceeded travel through Go code, how to test for them after wrapping, and how Cause records why a context ended.

Two sentinel errors live in the context package, and almost every server and every concurrent program meets them: context.Canceled and context.DeadlineExceeded. They look like any other error and behave differently, because they do not mean something went wrong. They mean someone stopped waiting. Mishandling them is a common cause of noisy dashboards, wrong status codes and retries that run after the caller has gone.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article explains where the errors come from, how they travel, how to test for them, and how to find out why a context ended. It belongs to the error handling guide, and the diagnosis page for the message itself is context canceled. Everything ran on Go 1.25.6.
Where they come from
A context.Context carries a signal that work should stop. There are two ways it ends, and ctx.Err() reports which:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
fmt.Println("live:", ctx.Err())
cancel()
fmt.Println("after cancel:", ctx.Err())
dctx, dcancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer dcancel()
<-dctx.Done()
fmt.Println("after timeout:", dctx.Err())
cancel() // cancelling again changes nothing
fmt.Println("still:", ctx.Err())
}live: <nil>
after cancel: context canceled
after timeout: context deadline exceeded
still: context canceledA live context has a nil Err. After a call to the cancel function it is context.Canceled. After its deadline passes it is context.DeadlineExceeded. Once set, it never changes: a second cancel() is harmless, and a context cancelled and then past its deadline still reports the first reason. Any code that holds the context can read ctx.Done(), a channel that closes at that moment, and stop what it is doing.
One more source is easy to forget. A context derived from another is cancelled when its parent is, with the parent's error. A request context is cancelled when the client disconnects, and every context derived from it inherits that, which is why a downstream database call can return context canceled when nobody on your side called cancel.
How they travel
Code that honors a context returns its error, and libraries wrap it in their own types. The check must look through the wrapping:
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(30 * time.Millisecond)
cancel()
}()
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL, nil)
_, err := http.DefaultClient.Do(req)
fmt.Printf("%T\n", err)
fmt.Println("== Canceled:", err == context.Canceled)
fmt.Println("Is Canceled:", errors.Is(err, context.Canceled))
fmt.Println("Is Deadline:", errors.Is(err, context.DeadlineExceeded))
}*url.Error
== Canceled: false
Is Canceled: true
Is Deadline: falseThe error is a *url.Error that wraps the context error. The direct comparison is false and errors.Is is true, which is the reason to always use Is. The same holds for database/sql, gRPC and most libraries: they return the context's error in some wrapper, and errors.Is finds it. It also means your own wrappers should preserve it with %w (wrapping with %w), because a layer that flattens the cancellation into a string turns a calm client disconnect into an unexplained failure further up.
Canceled is not a fault
The two errors need different treatment, and both differ from ordinary failures.
context.Canceled means someone cancelled. In a server it is nearly always the client going away, a closed browser tab or a gateway giving up. The work is no longer wanted, nobody will read the response, and nothing is broken. Return promptly, release what you hold, and record it at debug or info level. If you count errors for an alert, exclude cancellations, or a deploy that restarts clients will page someone.
context.DeadlineExceeded means time ran out, and whose time matters. If you set the deadline around a call to a dependency (WithTimeout(ctx, 2*time.Second) before calling the billing service), the dependency was too slow, and that is a real failure of something you rely on: log it, count it, maybe retry if the call is idempotent. If the deadline came from the caller's own context, it is the caller's budget and you should stop. The error value is the same in both cases. You tell them apart by where the context came from, which is a reason to name the deadline when you create it, as the next section shows.
In an HTTP handler this translates to the status mapping from mapping errors to HTTP statuses: 499 or no response for a cancellation, 504 or 503 for your own deadline.
Why it ended: Cause
The standard errors say how the context ended, not why. Since Go 1.20 you can attach a reason. WithCancelCause returns a cancel function that takes an error, and context.Cause(ctx) returns it:
package main
import (
"context"
"errors"
"fmt"
"time"
)
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(errors.Is(context.Cause(ctx), errShutdown))
tctx, tcancel := context.WithTimeoutCause(context.Background(), 10*time.Millisecond,
errors.New("billing call exceeded 10ms"))
defer tcancel()
<-tctx.Done()
fmt.Println("Err: ", tctx.Err())
fmt.Println("Cause:", context.Cause(tctx))
}Err: context canceled
Cause: server shutting down
true
Err: context deadline exceeded
Cause: billing call exceeded 10msErr stays context canceled or context deadline exceeded, so existing errors.Is checks keep working, while Cause returns the error you supplied: a shutdown, a named timeout, a failed sibling goroutine. In logs, print both when they differ. This answers the question "who cancelled this?" which is otherwise the hardest part of debugging a stream of context canceled lines (context cause goes into the API).
Writing code that cooperates
A function that takes a context has a duty to watch it. The shape is a loop or a blocking operation with a select:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func slowSum(ctx context.Context, n int) (int, error) {
sum := 0
for i := 1; i <= n; i++ {
select {
case <-ctx.Done():
return sum, fmt.Errorf("slowSum stopped at %d: %w", i, ctx.Err())
case <-time.After(5 * time.Millisecond):
sum += i
}
}
return sum, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 22*time.Millisecond)
defer cancel()
sum, err := slowSum(ctx, 100)
fmt.Println(sum > 0, errors.Is(err, context.DeadlineExceeded))
}true trueThe function returns the partial result and an error that wraps the context's, so the caller can use or discard the partial sum and test for the cause. Three rules keep this honest: check the context at every point where the function might block or loop for a long time, return ctx.Err() wrapped and not a new error, and never swallow the cancellation by retrying. A retry loop that ignores a done context is the bug that keeps a cancelled request alive (retryable and permanent errors makes the loop stop).
In tests
Cancellation is easy to test and rarely tested. Create a context, cancel it before the call, and assert that the function returns quickly with an error matching context.Canceled and without doing the work. A second test uses a short timeout against a fake dependency that blocks, and asserts a deadline error and that the function returned within a bound far shorter than the dependency's delay. These two tests catch the usual defects: a loop that never looks at ctx.Done(), a blocking call that ignores the context, and a wrapper that flattens the error. Keep the bounds generous, such as a second for a function that should return in milliseconds, so the tests are not flaky on a loaded CI machine (testing concurrent code).
Common mistakes
- Comparing with
==. It works on the raw error fromctx.Err()and fails through any wrapper. Useerrors.Is. - Logging every cancellation as an error. It buries real failures.
- Creating a fresh
context.Background()inside a request. The new context is never cancelled by the client, so work continues after they are gone. Pass the request's context down. - Forgetting
defer cancel(). A context made byWithCancelorWithTimeoutholds resources until cancelled, andgo vetwarns about the leak. - Treating a deadline as retryable without asking whose. Retrying inside a budget that is spent only adds load.
The package documentation for context covers every function mentioned, and the Go blog's Go Concurrency Patterns: Context is the original introduction.


