Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: WithCancelCause records why, and context.Cause reads it back · AfterFunc runs a function once a context ends · WithoutCancel keeps a request's values and drops its cancellation. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

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: a cause, a second cause, a child and cancel(nil) Go · cause.go
// 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:

Timeline · 5 events in order How package context grew, release by release Walk through the releases; the last four added what this lesson uses.
  1. 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.

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

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

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

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

  1. Question 1 of 2 A context from WithCancelCause was cancelled with cancel(errQuota). What do ctx.Err() and context.Cause(ctx) return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: context.Canceled and errQuota

    WithCancelCause adds a second channel of information: Err says that the context was cancelled, Cause says why.

  2. Question 2 of 2 cancel(errQuota) runs, then cancel(errUser). What does context.Cause(ctx) return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: errQuota, because the first cancellation wins

    A context ends once, with the cause of the call that ended it. The same holds for a child cancelled before its parent: it keeps its own 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: two registrations, a cancel and a late registration Go · afterfunc.go
// 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:

Interactive · message by message context.AfterFunc, from registration to stop Step through afterfunc.go and watch when the function runs and what stop reports.
context.AfterFunc, from registration to stop, step 6 of 6. Messages so far: main to the context: AfterFunc(ctx, f); the context to the function: starts f; the function to main: close(ran).mainstop() = falsethe context1 function waitingthe functionruns in its own goroutineAfterFunc(ctx, f)starts fclose(ran)

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two functions registered. main registers two functions with AfterFunc. Neither runs yet; each call returns a stop function of its own.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 stop := context.AfterFunc(ctx, f). stop() is called before ctx ends and returns true. What does that mean for f?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: f will never run for this registration

    stop returns true when it calls f off before it starts, and false when f has already been started or was already stopped.

  2. Question 2 of 2 Where does the function given to context.AfterFunc run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In a goroutine of its own, started after the context ends

    f runs in its own goroutine once the context ends, at once if it has already ended. Code that must know f has finished needs its own signal, such as a channel f closes.

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: an audit write after the client has gone Go · without.go
// 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

  1. Question 1 of 2 What do Err and Done report for context.WithoutCancel(req) after req has been cancelled?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Err is nil and Done is nil

    A context from WithoutCancel keeps its parent's values and nothing of its cancellation or deadline.

  2. Question 2 of 2 Why did without.go wrap the detached context in context.WithTimeout(detached, 2*time.Second)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Because WithoutCancel removes every limit, so the audit write needs one of its own

    Detaching trades the request's limit for none at all. Add a timeout sized for the work, here 2 s for an audit write.

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.

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.

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:

practice/runner.go and practice/runner_test.go

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:

The starter RunAll against its tests Python · practice_session.py
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.

  1. Question 1 of 9 What does context.Cause return for a context that has not ended?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: nil

    Like Err, Cause is nil while the context is live and reports why it ended once it has.

  2. Question 2 of 9 A function calls cancel(nil) on a context from WithCancelCause. What does context.Cause return?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: context.Canceled

    cancel(nil) is the same as a plain cancel: the cause is context.Canceled.

  3. Question 3 of 9 This handler must answer 429 when the quota ran out, and write nothing when the client went away. Which test belongs in the inner if?

    Read the code, then choose one answer.

    if err := ctx.Err(); err != nil {
    	if /* ? */ {
    		w.WriteHeader(http.StatusTooManyRequests)
    		return
    	}
    	return // the client has gone, so nothing is written
    }
    Show the answer: Question 3 of 9

    Answer: errors.Is(context.Cause(ctx), errQuota)

    Branch on the cause, with errors.Is; Err only says that the context was cancelled.

  4. Question 4 of 9 When does the function registered with context.AfterFunc(ctx, f) run?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: After ctx ends, unless stop was called first

    AfterFunc ties a function to the end of a context, and returns a stop function that unregisters it.

  5. Question 5 of 9 After cancel, the registered function has started, and stop() is called. What does stop return, and does it wait?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: false, and it does not wait for the function to finish

    afterfunc.go printed false for the stop called after its function ran. Code that needs to know when f has finished must have f signal it.

  6. Question 6 of 9 A cleanup is registered with AfterFunc on a request context that, under load, has sometimes already ended. What happens in that case?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: The cleanup runs at once, in its own goroutine, so it is never lost

    Registering late is safe: the function still runs exactly once, and stop then returns false.

  7. Question 7 of 9 Which part of the request's context does context.WithoutCancel keep?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: Its values, such as the request ID

    WithoutCancel keeps values and drops cancellation and deadline.

  8. Question 8 of 9 A request has 100 ms left. A handler detaches a write with context.WithoutCancel(r.Context()) and nothing else. How long may the write run?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: As long as it likes; the detached context has no deadline

    Detached work has no limit unless it is given one: WithTimeout(context.WithoutCancel(ctx), d).

  9. Question 9 of 9 A teammate detaches an audit write with context.Background() instead of WithoutCancel. What is lost?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: The request's values, such as its request ID and trace, so the audit line cannot be tied to the request

    Use WithoutCancel when the work belongs to a request but must outlive it; Background is for work that belongs to no request at all.

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:

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.