Skip to content
gohacks.dev
Basics

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.

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

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:

main.goGo
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())
}
Text
live: <nil>
after cancel: context canceled
after timeout: context deadline exceeded
still: context canceled

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

main.goGo
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))
}
Text
*url.Error
== Canceled: false
Is Canceled: true
Is Deadline: false

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

main.goGo
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))
}
Text
Err:   context canceled
Cause: server shutting down
true
Err:   context deadline exceeded
Cause: billing call exceeded 10ms

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

main.goGo
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))
}
Text
true true

The 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 from ctx.Err() and fails through any wrapper. Use errors.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 by WithCancel or WithTimeout holds resources until cancelled, and go vet warns 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.

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