How Go Context Cancellation Actually Propagates, and Where It Stops

Summary

Go context cancellation is cooperative: cancel closes Done and records an error, and every goroutine must observe it at a point where it can actually block. Cancellation propagates down to derived contexts, never up or sideways, while ctx.Err reports only a generic status and context.Cause carries the real reason, so use WithCancelCause at the top and log Cause at the bottom. Checking ctx.Err only at the top of a loop is not enough when a channel send or receive can block, so every blocking operation needs a select that includes <-ctx.Done() or it can leak goroutines. Avoid substituting context.Background() mid-call because it drops deadlines and values; use context.WithoutCancel for work that must outlive a request, and context.AfterFunc to unblock non-context calls such as net.Conn.Read. Always defer the cancel function returned by WithCancel, WithTimeout, or WithDeadline to avoid leaking child contexts and timers.

Cancelling a Go context doesn't stop anything. It closes a channel and records an error, and that's all. Every goroutine below that point has to notice on its own, at the exact place where it might block. Most context bugs come from code that assumes cancellation is enforced rather than cooperative: a goroutine that checks ctx.Err() in the wrong spot, a function that quietly swaps in context.Background(), or a blocking call that never looks at the context at all.

The examples below ran on Go 1.25.13. The context APIs they use have been stable since Go 1.21, and the Go 1.27 release notes list no changes to the package. The current stable release is go1.27.3, from 19 October 2026.

Cancellation flows down the tree, never up

Every With* call creates a child of the context you pass in. The context package documentation puts the rule in one sentence: "When a Context is canceled, all Contexts derived from it are also canceled." Cancelling a child has no effect on its parent or siblings. context.WithoutCancel (Go 1.21) is the deliberate exception. It keeps the parent's values but cuts the cancellation link.

func state(name string, ctx context.Context) {
    fmt.Printf("%-8s err=%v cause=%v\n", name, ctx.Err(), context.Cause(ctx))
}

func main() {
    root, cancelRoot := context.WithCancelCause(context.Background())
    child, cancelChild := context.WithTimeout(root, time.Hour)
    defer cancelChild()
    sibling, cancelSibling := context.WithCancel(root)
    detached := context.WithoutCancel(root)

    // Cancelling a child never travels up or sideways.
    cancelSibling()
    fmt.Println("after cancelSibling():")
    state("root", root)
    state("child", child)
    state("sibling", sibling)

    // Cancelling the root travels down to every derived context
    // except the one created with WithoutCancel.
    cancelRoot(errors.New("client disconnected"))
    fmt.Println("after cancelRoot(...):")
    state("root", root)
    state("child", child)
    state("sibling", sibling)
    state("detached", detached)
}
after cancelSibling():
root     err=<nil> cause=<nil>
child    err=<nil> cause=<nil>
sibling  err=context canceled cause=context canceled
after cancelRoot(...):
root     err=context canceled cause=client disconnected
child    err=context canceled cause=client disconnected
sibling  err=context canceled cause=context canceled
detached err=<nil> cause=<nil>

There are two details here that are easy to miss. ctx.Err() only ever says context canceled or context deadline exceeded, while context.Cause (Go 1.20) carries the reason the root was cancelled down to every descendant. Once a context is cancelled it stays that way: the sibling keeps its first cause even after the root is cancelled with a different one. If your logs say "context canceled" and you don't know why, use WithCancelCause at the top of the tree and log context.Cause(ctx) at the bottom. Since Go 1.26, signal.NotifyContext does this for you and records which signal arrived.

Why a goroutine ignores cancellation

The documentation for CancelFunc says it "does not wait for the work to stop." If the work is blocked somewhere that isn't watching ctx.Done(), it will never stop. This producer looks careful, since it checks the context on every iteration:

// produceLeaky ignores ctx while sending, so once the consumer stops
// reading, the send blocks forever.
func produceLeaky(ctx context.Context, out chan<- int) {
    for i := 0; ; i++ {
        if ctx.Err() != nil {
            return
        }
        out <- i
    }
}

// produce checks ctx at the point where it can actually block.
func produce(ctx context.Context, out chan<- int) {
    for i := 0; ; i++ {
        select {
        case out <- i:
        case <-ctx.Done():
            return
        }
    }
}

The test harness starts each producer 100 times, reads one value, calls cancel(), then counts goroutines with runtime.NumGoroutine:

for range 100 {
    ctx, cancel := context.WithCancel(context.Background())
    ch := make(chan int)
    go producer(ctx, ch)
    <-ch // take one value, then give up
    cancel()
}
leaky  goroutines left behind: 100
fixed  goroutines left behind: 0

The leaky version is parked on out <- i when cancel() runs. It never gets back to the top of the loop, so its ctx.Err() check never runs again. The rule is simple: every operation that can block (a channel send or receive, a timer, a lock you wait on for a long time) has to sit in a select that also includes <-ctx.Done(). A check at the top of the loop only helps with CPU-bound work between blocking points.

Go 1.27 makes this class of bug easier to find in running services. The goroutineleak profile, an experiment in Go 1.26, is now generally available in runtime/pprof and at /debug/pprof/goroutineleak. The release notes describe how it works: the garbage collector reports goroutines blocked on a channel or lock that no runnable goroutine can still reach. That describes the leaky producer exactly, because once the loop moves on, nothing holds ch. The notes also warn that leaks through primitives reachable from global variables can go undetected. We didn't run the profile here, because our local toolchain is 1.25. That description comes from the release notes.

A fresh Background() breaks the chain

Propagation only works if every function hands the context it received to the functions it calls. Swap in context.Background() partway down and the caller's deadline stops applying below that point:

// loadUserBroken drops the caller's ctx and starts a fresh one.
func loadUserBroken(ctx context.Context) error {
    return slowQuery(context.Background())
}

func loadUser(ctx context.Context) error {
    return slowQuery(ctx)
}

With a 100 ms timeout from the caller and a slowQuery that takes 2 seconds unless cancelled:

broken returned after 2s, err=<nil>
fixed  returned after 100ms, err=context deadline exceeded

The broken version doesn't return an error. It just ignores the deadline, which is why this mistake survives code review. It usually comes from a helper that was written before the code had a context to pass, or from someone fixing a compile error the quick way. If work really must outlive the request, such as an audit write that should finish after the client disconnects, use context.WithoutCancel(ctx). It keeps request-scoped values like trace IDs, while Background() throws them away.

The package docs add two related rules: pass the context explicitly as the first parameter instead of storing it in a struct, and never pass a nil context (use context.TODO() if you aren't sure yet). A context stored in a struct is easy to reuse after its request has finished, and from then on every call that uses it fails immediately.

Blocking calls that don't take a context

Some calls block without accepting a context. A plain net.Conn.Read is the common one. You can't select on it, but you can make it return early. context.AfterFunc (Go 1.21) runs a function in its own goroutine once the context is cancelled, and the net.Conn documentation says a read deadline applies to "any currently-blocked Read call". Setting the deadline to now unblocks the read:

func readWithContext(ctx context.Context, conn net.Conn, buf []byte) (int, error) {
    stop := context.AfterFunc(ctx, func() {
        conn.SetReadDeadline(time.Now())
    })
    defer stop()

    n, err := conn.Read(buf)
    if ctx.Err() != nil {
        return n, context.Cause(ctx)
    }
    return n, err
}

Against a net.Pipe peer that never writes, with a 200 ms timeout:

read returned after 200ms: context deadline exceeded

The deferred stop() unregisters the callback if the read finishes first, so a late cancellation can't put a deadline on a connection that's still in use. Closing the connection instead of setting a deadline also works when the connection is useless after cancellation anyway.

Always call cancel

The last mistake is the cheapest to catch. Every WithCancel, WithTimeout and WithDeadline registers the child with its parent and may start a timer. According to the package docs, "Failing to call the CancelFunc leaks the child and its children until the parent is canceled". When the parent is Background(), that never happens. go vet catches a discarded cancel function:

func fetch(parent context.Context) error {
    ctx, _ := context.WithTimeout(parent, time.Second)
    return ctx.Err()
}
lostcancel/main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

Write defer cancel() on the line right after you create the context, even when a timeout would fire eventually. It costs nothing, and it frees the timer as soon as the function returns rather than when the deadline passes.

GO CONTEXT GO VET GOROUTINE LEA CANCELLATION CANCELATION

  RELATED

  COMMENTS

0

No comment for this article.