Skip to content

Golang Context: What I Was Getting Wrong for Two Years

Golang Context: What I Was Getting Wrong for Two Years

Two in the morning, a pager alert, and one log line: context canceled. That was the whole error.

We had six things that could cancel that context. The client hanging up. A five second handler timeout. A circuit breaker tripping. A parent errgroup where any sibling failure kills the group. Graceful shutdown. And one defer cancel() I had written in a place I now regret. All six produce the same string, because context.Canceled is a single sentinel error shared by the entire program.

I spent about forty minutes adding print statements before I remembered that Go had already fixed this, in 1.20, and I had read the release notes and moved on the way you do.

So this is the post I needed at 2am: the parts of the context package that aren’t WithCancel and WithTimeout, what each one actually solves, and the before and after code. Everything here is in the standard library as of Go 1.26, and most of it landed in 1.20 and 1.21.

The log line that told me nothing

Here is roughly what the handler looked like.

func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithCancel(r.Context())
    defer cancel()

    if err := s.fanout(ctx); err != nil {
        // err is context.Canceled. Great. Which one?
        log.Printf("fanout failed: %v", err)
    }
}

ctx.Err() gives you context.Canceled or context.DeadlineExceeded. That is the entire vocabulary. It tells you the shape of the failure and nothing about the origin.

context.WithCancelCause changes that. The cancel function it hands back takes an error, and that error gets recorded on the context:

var errBudgetSpent = errors.New("query budget exhausted")

func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithCancelCause(r.Context())
    defer cancel(nil)

    go s.watchBudget(ctx, func() { cancel(errBudgetSpent) })

    if err := s.fanout(ctx); err != nil {
        log.Printf("fanout failed: %v (cause: %v)", err, context.Cause(ctx))
    }
}

The detail that made me comfortable adopting it: ctx.Err() still returns context.Canceled. Every errors.Is(err, context.Canceled) check in your codebase keeps working. The cause rides alongside, retrieved with context.Cause(ctx). Passing nil to the cancel function records context.Canceled, so the deferred happy-path call is harmless.

There is one rule worth memorising because it will confuse you otherwise. The first cancellation wins, and it wins upward. From the package documentation: if a parent is canceled with cause1 before its child is canceled with cause2, then Cause(parent) and Cause(child) both return cause1. If the child goes first, the two differ. So a request context canceled by the client disconnecting will mask whatever your inner code was about to report. That is usually what you want, but it means a cause is not a guarantee.

WithTimeoutCause and WithDeadlineCause do the same job for expiry. The cause is set when the clock runs out, not when you call the returned cancel function. So you can distinguish “the upstream API timed out” from “we shut this down deliberately” without threading a flag through four layers.

This also cleans up something that used to irritate me about errgroup. When you use errgroup.WithContext, the first goroutine to return an error cancels the group’s context. g.Wait() hands that error back to the caller, which is fine. But every other goroutine in the group only ever sees context.Canceled, so any logging they do on the way out is useless for working out what actually went wrong. Wrap the group context in WithCancelCause and cancel it yourself with the real error, and the siblings can log the reason they were shot rather than the fact that they were shot.

Worth saying plainly: none of this is free instrumentation. You still have to name the errors and remember to cancel with them. What it buys you is that the naming happens once, at the site where you already know the reason, instead of being reconstructed at 2am from timestamps.

Background work that dies with the request

This one cost me two weeks of missing analytics rows before I found it.

func (s *Server) createOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    order, err := s.orders.Create(ctx, parse(r))
    if err != nil {
        writeErr(w, err)
        return
    }

    go s.analytics.Record(ctx, order)
    writeJSON(w, order)
}

The goroutine inherits the request’s cancellation. As soon as the response is written and the handler returns, r.Context() is canceled, and the analytics write gets killed somewhere in the middle. Intermittently. Which made it look like a flaky sink rather than my bug.

context.WithoutCancel, added in Go 1.21, returns a context that keeps the parent’s values but is never canceled by it:

bg := context.WithoutCancel(ctx)

go func() {
    ctx, cancel := context.WithTimeout(bg, 10*time.Second)
    defer cancel()

    if err := s.analytics.Record(ctx, order); err != nil {
        log.Printf("analytics record failed: %v", err)
    }
}()

The values are the point. Trace IDs, request IDs, tenant identifiers all survive. My first attempt used context.Background() instead, which fixed the cancellation and quietly stripped every correlation ID off the analytics rows. I did not notice until someone asked me to trace an order end to end and I could not.

Put your own timeout on the derived context. A background goroutine with no deadline is a different bug wearing a hat.

Two sharp edges. Cause on a WithoutCancel context returns nil, not an error. And its Done() channel is nil, so a select waiting on <-ctx.Done() will block forever rather than never firing in a friendly way. If you have generic plumbing that assumes Done() is non-nil, test it.

AfterFunc, for the things that don’t take a context

Plenty of real code predates context. net.Conn reads. sync.Cond waits. That vendor SDK with a Close() method and no ctx parameter anywhere. You cannot cancel these; you can only reach over and break them.

context.AfterFunc gives you the hook. It runs a function in its own goroutine once the context is canceled, and hands back a stop function to unregister it:

func readWithCancel(ctx context.Context, conn net.Conn, b []byte) (int, error) {
    stopc := make(chan struct{})

    stop := context.AfterFunc(ctx, func() {
        conn.SetReadDeadline(time.Now())
        close(stopc)
    })

    n, err := conn.Read(b)

    if !stop() {
        // The AfterFunc already fired. Wait for it, then reset.
        <-stopc
        conn.SetReadDeadline(time.Time{})
        return n, ctx.Err()
    }
    return n, err
}

That pattern is adapted from the example in the package docs, which I had scrolled past several times before I understood what problem it was for.

The stop() return value is the part I still have to re-read. true means you got there first and the function will never run. false means either it already started in its own goroutine, or stop was already called. stop does not wait for your function to finish, so if you care whether it completed, you coordinate yourself. Hence the stopc channel above.

The same primitive merges two cancellation sources, which used to require a hand-rolled goroutine and a select:

func mergeCancel(ctx, cancelCtx context.Context) (context.Context, context.CancelFunc) {
    ctx, cancel := context.WithCancelCause(ctx)
    stop := context.AfterFunc(cancelCtx, func() {
        cancel(context.Cause(cancelCtx))
    })
    return ctx, func() {
        stop()
        cancel(context.Canceled)
    }
}

Cancellation you can actually grep for

The practical version of all this is boring and takes ten minutes. Declare your causes as package-level errors:

var (
    errClientGone   = errors.New("client disconnected")
    errBudgetSpent  = errors.New("query budget exhausted")
    errShuttingDown = errors.New("server shutting down")
)

Now they are greppable, they show up in autocomplete, and your tests can assert on them:

if !errors.Is(context.Cause(ctx), errBudgetSpent) {
    t.Fatalf("expected budget cancellation, got %v", context.Cause(ctx))
}

Cancellation causes are ordinary sentinel errors with a delivery mechanism attached, which is why they slot neatly into the error handling patterns I ship in Go. Same wrapping, same errors.Is, same conventions.

One change in your logging middleware pays for the whole exercise: log context.Cause(ctx) next to err, not instead of it. Most of the backends I build for clients now do this by default, and the 2am pages have gotten noticeably shorter.

The rules that didn’t change

None of the new functions rescue you from the old advice.

Do not store a Context inside a struct. Pass it explicitly as the first parameter, named ctx. The Go team wrote up the reasoning behind this and it is still the right call.

Still call your cancel functions on every path. go vet checks this, and failing to call cancel leaks the child context and its children until the parent goes away. WithCancelCause does not change that; it just means the call takes an argument.

WithValue is still for request-scoped data crossing API boundaries, not for optional function parameters. I have never once regretted being strict about this and I have regularly regretted being loose about it.

And if you are building pipelines of channels, the pipelines article remains the clearest explanation of using Done for cleanup that I know of.

What I would do this week

Grep your handlers for context.WithCancel(. Pick the one attached to the incident you remember most vividly. Swap it to WithCancelCause, give it a named sentinel error, and log context.Cause(ctx) alongside err in whatever middleware writes your request logs.

That is a ten minute change. The next time you get paged at two in the morning, the log line tells you which of the six things it was, and you go back to bed.