Go Module 14 – context and cancellation
Cancellation causes, AfterFunc and WithoutCancel
Record why a Go context was cancelled with WithCancelCause and Cause, run cleanup with AfterFunc, and let work outlive its request with WithoutCancel.
What you will learn
- Record why a context was cancelled with WithCancelCause, and read it back with context.Cause
- Run cleanup when a context ends with context.AfterFunc, and act on what its stop function returns
- Use WithoutCancel for work that must outlive its request, and give that work a limit of its own
- Name the causes of deadlines and signals, and log how every request ended
- Implement cleanup that runs exactly once, after interrupting a call that takes no context or merging two contexts
Before you start
What you’ll learn · Before you start
What you will learn
- Record why a context was cancelled with WithCancelCause, and read it back with context.Cause
- Run cleanup when a context ends with context.AfterFunc, and act on what its stop function returns
- Use WithoutCancel for work that must outlive its request, and give that work a limit of its own
- Name the causes of deadlines and signals, and log how every request ended
- Implement cleanup that runs exactly once, after interrupting a call that takes no context or merging two contexts
Before you start
On this page
ctx.Err() has two answers only, so a service that logs 4,000 lines of context canceled an hour cannot tell a user who
pressed Cancel from a quota that ran out or a deploy that stopped the server. Cancellation causes fix that: in cause.go
below, the same context canceled came with the cause quota exceeded.
You will record and read cancellation causes, run cleanup with context.AfterFunc when a context ends, and let work outlive its request with context.WithoutCancel. The output under each example is what Go 1.27.1 printed when it ran.
WithCancelCause records why, and context.Cause reads it back
context.WithCancelCause works like WithCancel, except that its cancel function takes an error, the
cancellation cause: The error recorded when a context ends, saying why: passed to the cancel function of context.WithCancelCause, or named in WithTimeoutCause. context.Cause reads it, while ctx.Err() still says only Canceled or DeadlineExceeded.. ctx.Err() still returns context.Canceled, so code that checks Err keeps
working, and context.Cause(ctx) returns the error that was passed. cause.go cancels with a quota error and then tries
a second cause:
// cause.go: WithCancelCause records why a context was cancelled, and context.Cause reads it back.
package main
import (
"context"
"errors"
"fmt"
)
var (
errQuota = errors.New("quota exceeded")
errUser = errors.New("user cancelled")
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
child, cancelChild := context.WithCancel(ctx)
defer cancelChild()
fmt.Println("before:", ctx.Err(), "|", context.Cause(ctx))
cancel(errQuota)
cancel(errUser) // too late: the first cancellation wins
fmt.Println("after: ", ctx.Err(), "|", context.Cause(ctx))
fmt.Println("child: ", child.Err(), "|", context.Cause(child))
fmt.Println("errors.Is(cause, errQuota):", errors.Is(context.Cause(ctx), errQuota))
other, cancelOther := context.WithCancelCause(context.Background())
cancelOther(nil)
fmt.Println("cancel(nil):", context.Cause(other), "| same as Err:", context.Cause(other) == other.Err())
} Output
before: <nil> | <nil> after: context canceled | quota exceeded child: context canceled | quota exceeded errors.Is(cause, errQuota): true cancel(nil): context canceled | same as Err: true
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run cause.go
Before the cancel, both were nil. After it, Err said context canceled and Cause said quota exceeded; the second
call changed nothing, because the first cancellation wins. The child reported its parent’s cause, and errors.Is found
the quota error in it, which is how a handler tells its endings apart.
| How the context ended | ctx.Err() | context.Cause(ctx) |
|---|---|---|
| it has not ended | nil | nil |
cancel(errQuota) |
context.Canceled |
errQuota |
cancel(nil), or a plain CancelFunc |
context.Canceled |
context.Canceled |
| its parent was cancelled with a cause | context.Canceled |
the parent’s cause |
Whenever no cause was given, Cause returns the same error as Err, so a handler can log both for every ending. Cancellation causes are one of several additions since the package joined the standard library:
- Go 1.7
context joins the standard library
The package moved from golang.org/x/net/context into the standard library, and net, net/http and os/exec began to take contexts for cancellation and deadlines.
- Go 1.20
WithCancelCause and Cause new API
A context can be cancelled with an error that says why, and context.Cause reads it back, while Err still says only Canceled or DeadlineExceeded.
- Go 1.21
AfterFunc, WithoutCancel and causes for deadlines new API
AfterFunc runs a function once a context ends, WithoutCancel detaches a context from its parent's cancellation, and WithDeadlineCause and WithTimeoutCause name the reason when time runs out.
- Go 1.24
Contexts in tests
testing's T.Context and B.Context return a context that is cancelled when the test completes, before its cleanup functions run.
- Go 1.26
A signal's name as the cause behaviour change
signal.NotifyContext now cancels its context with a cause that says which signal arrived, so a log can tell an interrupt from a terminate.
The timeline has 5 events, from Go 1.7 to Go 1.26.
Checkpoint
Check: WithCancelCause and Cause
AfterFunc runs a function once a context ends
context.AfterFunc(ctx, f) registers f to run after ctx ends, in a goroutine of its own, and returns a stop function.
Calling stop before the context ends calls f off and returns true; once f has started, stop returns false, and it never
waits for f to finish. Cleanup that must happen however a context ends, closing a connection or telling another part
of the program, fits AfterFunc:
// afterfunc.go: context.AfterFunc runs a function in its own goroutine once a context ends, and stop can call it off.
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
ran := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
fmt.Println("after func ran")
close(ran)
})
stopUnused := context.AfterFunc(ctx, func() { fmt.Println("never printed") })
fmt.Println("stop before the cancel:", stopUnused())
cancel()
<-ran
fmt.Println(ctx.Err(), "shutting down")
fmt.Println("stop after the function started:", stop())
late := make(chan string, 1)
context.AfterFunc(ctx, func() { late <- "ran at once" })
fmt.Println("AfterFunc on a context that is already done:", <-late)
} Output
stop before the cancel: true after func ran context canceled shutting down stop after the function started: false AfterFunc on a context that is already done: ran at once
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run afterfunc.go
The stop called before the cancel returned true, so its function never ran. After the cancel, the other function
printed after func ran, and its stop then returned false. A function registered on a context that was already done
ran at once. Step through the run:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two functions registered. main registers two functions with AfterFunc. Neither runs yet; each call returns a stop function of its own.
- One is called off. main calls the second function's stop before any cancel. It returns true, because it stopped that function from ever running.
- cancel ends the context. main calls cancel. The context is done, and it starts the registered function in a goroutine of its own, without waiting for it.
- The function runs. f prints "after func ran" and closes a channel, which main is waiting on, so main goes on only after f has run.
- Too late to stop. main calls the first function's stop. It returns false, because the function had already started; stop never waits for it to finish.
- A late registration. AfterFunc on a context that is already done starts its function at once, in its own goroutine. Every registration is independent; none replaces another.
Checkpoint
Check: AfterFunc and its stop
WithoutCancel keeps a request’s values and drops its cancellation
Some work must finish after its request is gone: the audit record of a payment that succeeded, or a metric. WithoutCancel returns a context with ctx’s values, no deadline and a nil Done channel, so its parent can no longer cancel it:
// without.go: WithoutCancel keeps a request's values but not its cancellation, so an audit write can finish.
package main
import (
"context"
"fmt"
"time"
)
type requestIDKey struct{}
// audit pretends to write an audit record, which takes 30 ms, unless ctx ends first.
func audit(ctx context.Context, event string) error {
select {
case <-time.After(30 * time.Millisecond):
fmt.Printf("audit written: %s for %v\n", event, ctx.Value(requestIDKey{}))
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
req, clientGone := context.WithCancel(context.WithValue(context.Background(), requestIDKey{}, "req-7f3a"))
clientGone() // the client disconnects just after the payment succeeded
fmt.Println("with the request's context:", audit(req, "payment captured"))
detached := context.WithoutCancel(req)
fmt.Println("detached: Err =", detached.Err(), "| Done is nil:", detached.Done() == nil)
ctx, cancel := context.WithTimeout(detached, 2*time.Second) // a limit of its own
defer cancel()
fmt.Println("with WithoutCancel and its own 2 s:", audit(ctx, "payment captured"))
} Output
with the request's context: context canceled detached: Err = <nil> | Done is nil: true audit written: payment captured for req-7f3a with WithoutCancel and its own 2 s: <nil>
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run without.go
With the request’s context, the audit gave up at once, because the client had already left. The detached context reported Err nil and a nil Done, and the audit, with the same request ID, was written. The 2 s timeout matters: WithoutCancel removes every limit, so detached work needs one of its own.
Detaching with context.Background()
context.Background() also escapes the request’s cancellation, but it drops the request’s values, the request ID
and the trace with them. WithoutCancel keeps them.
Timers and signals have causes too. WithTimeoutCause and WithDeadlineCause name the reason when time runs out. Since Go 1.26, signal.NotifyContext cancels with a cause that names the signal. AfterFunc can also interrupt a call that takes no context.
Checkpoint
Check: WithoutCancel
Causes for deadlines, children and signals, and a log line for every ending Pro: opens with a Pro, Premium or Ultimate pass
Causes are only useful if every path that ends a context sets one and every log line shows it. This part measures what Err and Cause report for timeouts and early cancels, shows which cause a child keeps, logs three endings with slog, and reads the signal that stopped a program.
In this part:
- Deadlines with a cause: what Err and Cause report in every case
- One log line per ending, with err and cause
- The signal that stopped a program, as a cause (Go 1.26)
3 runnable examples (Go) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
AfterFunc and WithoutCancel at work: interrupted copies, merged contexts, detached writes Pro: opens with a Pro, Premium or Ultimate pass
Most of the code that has to react to a cancel does not take a context: io.Copy, a read from a connection, a wait on another component. This part interrupts such a copy with AfterFunc and lets stop decide the cleanup, merges two contexts into one, and gives detached writes a limit and a place in shutdown.
In this part:
- Interrupting a copy that takes no context, and cleaning up exactly once
- Ending a job when either of two contexts ends
- Detached writes with a limit of their own, and a shutdown that waits for them
1 diagram · 3 runnable examples (Go) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a job runner that says why it stopped
practice/runner.go has RunAll to write: run jobs under a context derived with WithCancelCause, cancel it with ErrQuotaExceeded once the units pass the quota, and return the cause. practice/runner_test.go tests the quota, a parent’s own cause and a job’s error:
Go (runner.go) · practice/runner.go
// Package practice holds a small job runner for you to finish.
package practice
import (
"context"
"errors"
)
// ErrQuotaExceeded is the cause RunAll cancels with when the jobs use more units than the quota allows.
var ErrQuotaExceeded = errors.New("quota exceeded")
// Job does some work with ctx and reports how many units of quota it used.
type Job func(ctx context.Context) (units int, err error)
// RunAll runs jobs in order, each with a context derived from ctx with context.WithCancelCause. When the units used so
// far pass quota, it cancels that context with ErrQuotaExceeded and stops. It returns nil when every job ran, a job's
// own error as it is, or, when the context ended, context.Cause of it.
func RunAll(ctx context.Context, quota int, jobs []Job) error {
return nil // write it
} Go (runner_test.go) · practice/runner_test.go
package practice
import (
"context"
"errors"
"testing"
)
var errUserCancelled = errors.New("user cancelled")
// job returns a Job that records its run and uses units.
func job(ran *int, units int) Job {
return func(ctx context.Context) (int, error) {
*ran++
return units, nil
}
}
func TestRunAllWithinQuota(t *testing.T) {
ran := 0
err := RunAll(t.Context(), 10, []Job{job(&ran, 3), job(&ran, 3), job(&ran, 3)})
if err != nil || ran != 3 {
t.Errorf("RunAll = %v after %d jobs; want nil after 3", err, ran)
}
}
func TestRunAllQuotaCause(t *testing.T) {
ran := 0
err := RunAll(t.Context(), 5, []Job{job(&ran, 3), job(&ran, 3), job(&ran, 3)})
if !errors.Is(err, ErrQuotaExceeded) || ran != 2 {
t.Errorf("RunAll = %v after %d jobs; want ErrQuotaExceeded after 2", err, ran)
}
}
func TestRunAllParentCause(t *testing.T) {
ctx, cancel := context.WithCancelCause(t.Context())
cancel(errUserCancelled)
ran := 0
err := RunAll(ctx, 10, []Job{job(&ran, 1)})
if !errors.Is(err, errUserCancelled) || ran != 0 {
t.Errorf("RunAll on a cancelled context = %v after %d jobs; want errUserCancelled and no job", err, ran)
}
}
func TestRunAllJobError(t *testing.T) {
errDisk := errors.New("disk full")
err := RunAll(t.Context(), 10, []Job{func(context.Context) (int, error) { return 0, errDisk }})
if !errors.Is(err, errDisk) {
t.Errorf("RunAll = %v; want the job's own error", err)
}
} The starter code compiles, and every test fails:
from session import run
# The starter code of practice/ compiles, and every test fails until RunAll is written.
run(
"""
cd practice && go test .
""",
expect_failure=["cd practice && go test ."],
) Output
$ cd practice && go test .
--- FAIL: TestRunAllWithinQuota (0.00s)
runner_test.go:23: RunAll = <nil> after 0 jobs; want nil after 3
--- FAIL: TestRunAllQuotaCause (0.00s)
runner_test.go:31: RunAll = <nil> after 0 jobs; want ErrQuotaExceeded after 2
--- FAIL: TestRunAllParentCause (0.00s)
runner_test.go:41: RunAll on a cancelled context = <nil> after 0 jobs; want errUserCancelled and no job
--- FAIL: TestRunAllJobError (0.00s)
runner_test.go:49: RunAll = <nil>; want the job's own error
FAIL
FAIL example.com/practice 1.890s
FAIL
[exit status 1]
This output changes from run to run: go test prints how long each test and the whole run took, which differs from one run to the next.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
You are done when go test . passes in the practice folder. As a first hint, test ctx.Err() before every job and
return context.Cause(ctx) once it is set; a parent cancelled with a cause hands that cause to your derived context.
The questions below cover causes, AfterFunc and WithoutCancel, and every answer comes with its reason.
Practice that adapts to you
Practise this lesson
9 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2 to 5 years): What does context.Cause return for a context ended by a plain CancelFunc?
It returns context.Canceled, the same as ctx.Err(), because no cause was given. A context that timed out without a named cause returns context.DeadlineExceeded the same way, and a context that has not ended returns nil. Only WithCancelCause, WithDeadlineCause and WithTimeoutCause let code record a cause of its own.
Three longer questions of the module bank build on this lesson:
-
Mid to senior
Your API logs about 4,000 "context canceled" errors an hour, and nobody can tell client disconnects from quota limits, slow dependencies or deploys. Design the change: what each component records, what the log line holds, and how alerts use it.
-
Mid to senior
A download manager streams files through io.Copy, which takes no context, into name.part files. When the user cancels, the copy must stop and the partial file must be removed exactly once, even if the download finishes at the same moment. Design it with context.AfterFunc.
-
Mid to staff
After a payment succeeds, an audit record must be written even if the client disconnects. A teammate starts a goroutine that writes it with context.Background(). What is lost, how do you do it with WithoutCancel, and what stops detached writes from piling up or being cut off by a deploy?
Key takeaways
- WithCancelCause records an error as the cause; ctx.Err() still says context.Canceled, context.Cause returns the error, the first cancellation wins, and children inherit their parent’s cause.
- AfterFunc runs a function in its own goroutine once a context ends; stop returns true if it called the function off and false once it has started.
- WithoutCancel keeps a request’s values and drops its cancellation and deadline, so detached work needs a timeout of its own.
Terms in this lesson
- cancellation cause
- The error recorded when a context ends, saying why: passed to the cancel function of context.WithCancelCause, or named in WithTimeoutCause. context.Cause reads it, while ctx.Err() still says only Canceled or DeadlineExceeded.
References
- Package context: WithCancelCause (The Go Authors)
- Package context: AfterFunc (The Go Authors)
- Package context: WithoutCancel (The Go Authors)
- Go 1.20 Release Notes (The Go Authors)
- Go 1.21 Release Notes (The Go Authors)
- Go 1.26 Release Notes (The Go Authors)
- Package os/signal: NotifyContext (The Go Authors)
- Package io: Pipe (The Go Authors)
- Go 1.7 Release Notes (The Go Authors)
Report a problem with this lesson
Kept only in this browser. Your Learn progress