Skip to content
gohacks.dev
Basics

Context Canceled Error in Go and Who Is Cancelling It

Find who cancelled a context in Go and fix the error: client disconnects, a deferred cancel that runs too early, errgroup siblings and detached work.

7 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

You see context canceled in a log, in a database error or as the text of a failed HTTP call. It is one of the most searched Go error messages, because it is easy to produce and hard to trace. The message says a context ended. It does not say who ended it, and the person who calls cancel is rarely in the same function as the code that reports the error.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This page covers the causes in order of how often they occur, gives a reproduction of each with its fix, and ends with a way to find the canceller in a program you did not write. The background on how the error travels is in errors and context cancellation. Everything ran on Go 1.25.6.

The smallest reproduction

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func work(ctx context.Context) error {
	select {
	case <-time.After(time.Second):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		time.Sleep(20 * time.Millisecond)
		cancel()
	}()
	fmt.Println(work(ctx))
}
Text
context canceled

The work would take a second, something cancels at 20 ms, and work returns context canceled. The error is correct and the interesting question is why cancel ran. These are the usual answers.

Cause 1: the client went away

In an HTTP server, the request's context (r.Context()) is cancelled when the client closes the connection, when the HTTP/2 stream is reset, or when the handler returns. A slow database query that was started for a request whose browser tab has been closed ends with context canceled.

This one needs no fix in the code that sees the error. It is the system working: the result was no longer wanted. The fixes are in what you do with the error. Return promptly, do not retry, and record it at debug or info level. In metrics, count it separately from failures, or a mobile client on a flaky network will look like an outage. A status of 499 in the access log, the nginx convention for "client closed request", makes these easy to filter.

Cause 2: defer cancel() runs before the work does

This is the bug behind most "mystery" cancellations. A function creates a context with a cancel function, starts asynchronous work with it, and returns. The defer cancel() runs at return and cancels a context the goroutine still needs:

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func process(ctx context.Context, done chan<- string) {
	select {
	case <-time.After(30 * time.Millisecond):
		done <- "finished"
	case <-ctx.Done():
		done <- "aborted: " + ctx.Err().Error()
	}
}

func start() <-chan string {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel() // runs when start returns, before the goroutine is done

	done := make(chan string, 1)
	go process(ctx, done)
	return done
}

func main() {
	fmt.Println(<-start())
}
Text
aborted: context canceled

The goroutine was given a context that start cancels as it returns, a few microseconds later, so the work aborts with context canceled although the one-second timeout never came near. The rule is that whoever creates a context owns its cancellation, and must not cancel it while something still uses it. Two fixes work. Move the creation into the goroutine so it cancels when it finishes:

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func process(ctx context.Context, done chan<- string) {
	select {
	case <-time.After(30 * time.Millisecond):
		done <- "finished"
	case <-ctx.Done():
		done <- "aborted: " + ctx.Err().Error()
	}
}

func start() <-chan string {
	done := make(chan string, 1)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		process(ctx, done)
	}()
	return done
}

func main() {
	fmt.Println(<-start())
}
Text
finished

Or let the caller own the context and pass it in, with a Wait or a channel so the caller knows when to cancel. Whichever you pick, go vet's lostcancel check is the one that warns when a cancel function is never used, and reading defer cancel() next to a go statement should make you stop and check.

Cause 3: background work inherits the request's context

A handler that saves data and then sends an email in a goroutine, passing r.Context(), loses the email whenever the client disconnects before the send finishes, or when the handler returns, since the server cancels the request context at that point. For work that must outlive the request, detach it. Go 1.21 added context.WithoutCancel, which keeps the values (a request ID, a trace) and drops the cancellation:

main.goGo
package main

import (
	"context"
	"fmt"
	"sort"
	"time"
)

type ctxKey struct{}

func sendEmail(ctx context.Context, out chan<- string) {
	time.Sleep(20 * time.Millisecond)
	out <- fmt.Sprintf("sent (request %v, ctx error: %v)", ctx.Value(ctxKey{}), ctx.Err())
}

func main() {
	out := make(chan string, 2)

	reqCtx, cancel := context.WithCancel(context.WithValue(context.Background(), ctxKey{}, "req_42"))

	go sendEmail(reqCtx, out) // inherits the request's cancellation
	go sendEmail(context.WithoutCancel(reqCtx), out)

	cancel() // the handler returns
	time.Sleep(60 * time.Millisecond)
	close(out)
	var lines []string
	for line := range out {
		lines = append(lines, line)
	}
	sort.Strings(lines)
	for _, line := range lines {
		fmt.Println(line)
	}
}
Text
sent (request req_42, ctx error: <nil>)
sent (request req_42, ctx error: context canceled)

Both emails run for 20 ms, and the lines are sorted because the two goroutines finish in either order. The one given reqCtx reports context canceled in its error because the handler cancelled first, while the detached one is clean and still carries the request ID for logging. A detached context has no deadline either, so give the background work its own: context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second). Work that must not be lost at all belongs in a queue, not a goroutine; a process restart cancels everything regardless.

Cause 4: a sibling failed in an errgroup

errgroup.WithContext returns a context that is cancelled when any function in the group returns an error. Every other member that is watching it then reports context canceled. In the logs these look like several failures when there is one:

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

func main() {
	g, ctx := errgroup.WithContext(context.Background())
	g.Go(func() error {
		time.Sleep(10 * time.Millisecond)
		return errors.New("disk full")
	})
	for i := range 2 {
		g.Go(func() error {
			select {
			case <-ctx.Done():
				fmt.Printf("worker %d: %v\n", i, ctx.Err())
				return ctx.Err()
			case <-time.After(time.Second):
				return nil
			}
		})
	}
	fmt.Println("group error:", g.Wait())
}
Text
worker 1: context canceled
worker 0: context canceled
group error: disk full

Two workers print context canceled, and the group returns disk full, because it keeps the first error. When you see several cancellations next to one real failure, the real one is the cause, and the cancellations are its echo. Log the group's returned error, not each member's, and have members return ctx.Err() quietly (errgroup covers the semantics).

Cause 5: shutdown

On a termination signal, programs cancel a root context so that every request and worker stops. The errors that follow, context canceled from in-flight database calls and clients, are expected. A graceful shutdown waits for in-flight requests with a deadline instead of cancelling them at once, so they finish with real results (graceful shutdown). If you see a burst of cancellations at deploy time, check whether the server cancels its context before it drains.

Finding the canceller

When none of these fits, make the context say who cancelled it. WithCancelCause takes an error at cancel time, and context.Cause reads it back:

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancelCause(context.Background())

	// Wherever the program cancels, say why.
	cancel(errors.New("auth middleware: token expired mid-request"))

	fmt.Println(ctx.Err())
	fmt.Println(context.Cause(ctx))
}
Text
context canceled
auth middleware: token expired mid-request

Change every cancel() you control to cancel(err) with a message naming the site, and log context.Cause(ctx) wherever you log a context error. For contexts you do not create, a search for cancel and Done() in the call path, plus the list above, finds it. The cause turns "who cancelled?" from an investigation into a log line (context cause).

Reading the error in context

Where you see the message matters. From database/sql it means the query's context ended, and the connection is returned to the pool cleanly, so there is nothing to repair. From the HTTP client it arrives as Get "https://...": context canceled, a *url.Error wrapping the cause. From a gRPC call it surfaces as a status with code Canceled. In a worker, it is often the last line of a shutdown. What you do next depends on whether anything was left half-done: a cancelled write inside a transaction rolls back and needs no cleanup, but a cancelled multi-step workflow that wrote to two systems may need compensation. Treat cancellation as a normal exit path in the design of every multi-step operation, and the error stops being alarming.

Checklist

  1. Is it a client disconnect? Look for a 499 or a closed connection at the same time. If so, no fix is needed beyond quieter logging.
  2. Is there a defer cancel() in a function that also starts a goroutine with that context?
  3. Is a goroutine launched from a handler using r.Context() for work that should outlive the request?
  4. Is an errgroup involved, and is a sibling failing first?
  5. Is it shutdown time?
  6. Still unknown: switch to WithCancelCause and log the cause.

For the API reference, see the context package, and for the signal-driven version of the same idea, signal.NotifyContext.

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.