A canceled Go context normally reports one of two broad states through ctx.Err(): context.Canceled or context.DeadlineExceeded. That is enough to stop work, but it can discard the event that triggered cancellation. A worker failure, shutdown request, quota rejection, and explicit abort can all collapse into the same context.Canceled value.

Go provides cause-aware context functions for cases where the cancellation signal and the diagnostic error need to travel together. context.WithCancelCause creates a derived context whose cancel function accepts an error, while context.Cause retrieves the recorded cause.

Cancellation state and cancellation cause are separate

The distinction is visible in a small example:

package main

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

var errQueueClosed = errors.New("queue closed")

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

    fmt.Println(ctx.Err())
    fmt.Println(context.Cause(ctx))
}

After cancellation, ctx.Err() is still context.Canceled. Code that already checks the standard context sentinel keeps the same control-flow behavior. context.Cause(ctx) returns errQueueClosed, preserving the more specific reason.

This separation is useful because cancellation is often a control signal rather than the complete error model of an operation. A function waiting on ctx.Done() can stop without needing to understand every possible cause. Code at a boundary that records an operation result can inspect the cause and retain more detail.

The first cancellation fixes the cause

A context has one effective cancellation event. Once it has been canceled, a later call to its CancelCauseFunc does not replace the existing cause.

ctx, cancel := context.WithCancelCause(context.Background())

cancel(errors.New("worker failed"))
cancel(errors.New("shutdown requested"))

fmt.Println(context.Cause(ctx)) // worker failed

This matters when several goroutines can request cancellation. The cause is not an accumulating error collection and it is not a last-write-wins field. The first cancellation that reaches the context determines its cause.

If cancel(nil) is called first, the context is canceled and its cause becomes context.Canceled. A later non-nil error cannot replace it. Cleanup code should therefore avoid casually calling a cause-aware cancel function with nil before the operation has finished deciding its outcome.

A common pattern is still safe when the deferred call runs only after all meaningful cancellation paths have completed:

ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)

// Work may call cancel(err) before this function returns.

If a non-nil cause was already recorded, the deferred call leaves it unchanged.

Causes propagate through derived contexts

Context cancellation forms a tree. When a parent is canceled, descendants that have not already been canceled receive that cancellation. Cause-aware cancellation follows the same propagation model.

parent, stopParent := context.WithCancelCause(context.Background())
child, stopChild := context.WithCancelCause(parent)

defer stopChild(nil)

stopParent(errors.New("service stopping"))

fmt.Println(context.Cause(parent)) // service stopping
fmt.Println(context.Cause(child))  // service stopping

A child can establish its own cause first. In that case, a later parent cancellation does not overwrite the child’s existing state.

parent, stopParent := context.WithCancelCause(context.Background())
child, stopChild := context.WithCancelCause(parent)

stopChild(errors.New("child operation rejected"))
stopParent(errors.New("service stopping"))

fmt.Println(context.Cause(parent)) // service stopping
fmt.Println(context.Cause(child))  // child operation rejected

The ordering gives each derived operation a chance to retain its own terminal condition while still inheriting an earlier cancellation from its parent.

Cause-aware deadlines keep the normal deadline sentinel

Go also provides context.WithDeadlineCause and context.WithTimeoutCause. These functions attach a specific cause to expiration while preserving context.DeadlineExceeded as the value returned by Err().

ctx, cancel := context.WithTimeoutCause(
    context.Background(),
    250*time.Millisecond,
    errors.New("metadata lookup exceeded its budget"),
)
defer cancel()

<-ctx.Done()

fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))

When the timer expires, ctx.Err() reports context.DeadlineExceeded, while context.Cause(ctx) returns the supplied error. Existing timeout checks can remain intact, and logging or result mapping can retain operation-specific context.

The CancelFunc returned by WithTimeoutCause and WithDeadlineCause does not set the configured expiration cause when it is called directly. The supplied cause applies when the deadline expires. Direct cancellation before that point produces ordinary cancellation semantics.

Returning a cause from a worker boundary

Cause-aware contexts are most useful when cancellation coordinates multiple pieces of work but one boundary owns the final result. Consider a worker loop that stops siblings after a terminal error:

func run(ctx context.Context, jobs <-chan Job) error {
    ctx, cancel := context.WithCancelCause(ctx)
    defer cancel(nil)

    var wg sync.WaitGroup

    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()

            for {
                select {
                case <-ctx.Done():
                    return
                case job, ok := <-jobs:
                    if !ok {
                        return
                    }
                    if err := process(ctx, job); err != nil {
                        cancel(err)
                        return
                    }
                }
            }
        }()
    }

    wg.Wait()

    if err := context.Cause(ctx); err != nil && !errors.Is(err, context.Canceled) {
        return err
    }
    return nil
}

The workers only need the cancellation channel to stop. The coordinating function reads the cause after all workers exit and can return the specific processing error instead of reducing it to context.Canceled.

This pattern still needs an explicit policy for simultaneous failures. Since the first cancellation wins, it preserves one triggering error rather than every error produced near the same time. If every worker error must be retained, an error collector or another aggregation mechanism is a better fit.

Cause does not replace ordinary error returns

A context cause should describe cancellation of the operation represented by that context. It is not a general-purpose channel for moving arbitrary errors between functions.

A synchronous function that fails can still return its error normally. Recording that error as a cancellation cause becomes useful when the failure must also signal concurrent work to stop or when descendants need access to the same terminal condition.

Keeping that boundary clear avoids turning Context into hidden error storage. Function return values remain the direct path for local failures; cancellation causes add diagnostic detail to a cancellation signal that already needs to cross goroutine or API boundaries.

Cause-aware cancellation fits systems where broad context sentinels are useful for control flow but too coarse for final diagnostics. Err() can continue to answer whether work was canceled or exceeded a deadline, while Cause() can retain the event that ended the operation. The useful constraint is simple: attach a cause only when cancellation itself carries meaningful domain or operational information.