{"id":543,"date":"2026-08-04T05:04:17","date_gmt":"2026-08-04T05:04:17","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/golang-context-what-i-was-getting-wrong-for-two-years\/"},"modified":"2026-08-04T05:04:17","modified_gmt":"2026-08-04T05:04:17","slug":"golang-context-what-i-was-getting-wrong-for-two-years","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/golang-context-what-i-was-getting-wrong-for-two-years\/","title":{"rendered":"Golang Context: What I Was Getting Wrong for Two Years"},"content":{"rendered":"<p>Two in the morning, a pager alert, and one log line: <code>context canceled<\/code>. That was the whole error.<\/p>\n<p>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 <code>defer cancel()<\/code> I had written in a place I now regret. All six produce the same string, because <code>context.Canceled<\/code> is a single sentinel error shared by the entire program.<\/p>\n<p>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.<\/p>\n<p>So this is the post I needed at 2am: the parts of the <code>context<\/code> package that aren&rsquo;t <code>WithCancel<\/code> and <code>WithTimeout<\/code>, 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.<\/p>\n<h2 id=\"the-log-line-that-told-me-nothing\">The log line that told me nothing<\/h2>\n<p>Here is roughly what the handler looked like.<\/p>\n<pre><code class=\"language-go\">func (s *Server) handle(w http.ResponseWriter, r *http.Request) {\n    ctx, cancel := context.WithCancel(r.Context())\n    defer cancel()\n\n    if err := s.fanout(ctx); err != nil {\n        \/\/ err is context.Canceled. Great. Which one?\n        log.Printf(&quot;fanout failed: %v&quot;, err)\n    }\n}\n<\/code><\/pre>\n<p><code>ctx.Err()<\/code> gives you <code>context.Canceled<\/code> or <code>context.DeadlineExceeded<\/code>. That is the entire vocabulary. It tells you the shape of the failure and nothing about the origin.<\/p>\n<p><code>context.WithCancelCause<\/code> changes that. The cancel function it hands back takes an error, and that error gets recorded on the context:<\/p>\n<pre><code class=\"language-go\">var errBudgetSpent = errors.New(&quot;query budget exhausted&quot;)\n\nfunc (s *Server) handle(w http.ResponseWriter, r *http.Request) {\n    ctx, cancel := context.WithCancelCause(r.Context())\n    defer cancel(nil)\n\n    go s.watchBudget(ctx, func() { cancel(errBudgetSpent) })\n\n    if err := s.fanout(ctx); err != nil {\n        log.Printf(&quot;fanout failed: %v (cause: %v)&quot;, err, context.Cause(ctx))\n    }\n}\n<\/code><\/pre>\n<p>The detail that made me comfortable adopting it: <code>ctx.Err()<\/code> still returns <code>context.Canceled<\/code>. Every <code>errors.Is(err, context.Canceled)<\/code> check in your codebase keeps working. The cause rides alongside, retrieved with <code>context.Cause(ctx)<\/code>. Passing <code>nil<\/code> to the cancel function records <code>context.Canceled<\/code>, so the deferred happy-path call is harmless.<\/p>\n<p>There is one rule worth memorising because it will confuse you otherwise. The first cancellation wins, and it wins upward. From the <a href=\"https:\/\/pkg.go.dev\/context\" rel=\"nofollow noopener\" target=\"_blank\">package documentation<\/a>: if a parent is canceled with cause1 before its child is canceled with cause2, then <code>Cause(parent)<\/code> and <code>Cause(child)<\/code> 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.<\/p>\n<p><code>WithTimeoutCause<\/code> and <code>WithDeadlineCause<\/code> 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 &ldquo;the upstream API timed out&rdquo; from &ldquo;we shut this down deliberately&rdquo; without threading a flag through four layers.<\/p>\n<p>This also cleans up something that used to irritate me about <code>errgroup<\/code>. When you use <code>errgroup.WithContext<\/code>, the first goroutine to return an error cancels the group&rsquo;s context. <code>g.Wait()<\/code> hands that error back to the caller, which is fine. But every other goroutine in the group only ever sees <code>context.Canceled<\/code>, so any logging they do on the way out is useless for working out what actually went wrong. Wrap the group context in <code>WithCancelCause<\/code> 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.<\/p>\n<p>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.<\/p>\n<h2 id=\"background-work-that-dies-with-the-request\">Background work that dies with the request<\/h2>\n<p>This one cost me two weeks of missing analytics rows before I found it.<\/p>\n<pre><code class=\"language-go\">func (s *Server) createOrder(w http.ResponseWriter, r *http.Request) {\n    ctx := r.Context()\n\n    order, err := s.orders.Create(ctx, parse(r))\n    if err != nil {\n        writeErr(w, err)\n        return\n    }\n\n    go s.analytics.Record(ctx, order)\n    writeJSON(w, order)\n}\n<\/code><\/pre>\n<p>The goroutine inherits the request&rsquo;s cancellation. As soon as the response is written and the handler returns, <code>r.Context()<\/code> 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.<\/p>\n<p><code>context.WithoutCancel<\/code>, added in Go 1.21, returns a context that keeps the parent&rsquo;s values but is never canceled by it:<\/p>\n<pre><code class=\"language-go\">bg := context.WithoutCancel(ctx)\n\ngo func() {\n    ctx, cancel := context.WithTimeout(bg, 10*time.Second)\n    defer cancel()\n\n    if err := s.analytics.Record(ctx, order); err != nil {\n        log.Printf(&quot;analytics record failed: %v&quot;, err)\n    }\n}()\n<\/code><\/pre>\n<p>The values are the point. Trace IDs, request IDs, tenant identifiers all survive. My first attempt used <code>context.Background()<\/code> 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.<\/p>\n<p>Put your own timeout on the derived context. A background goroutine with no deadline is a different bug wearing a hat.<\/p>\n<p>Two sharp edges. <code>Cause<\/code> on a <code>WithoutCancel<\/code> context returns nil, not an error. And its <code>Done()<\/code> channel is nil, so a <code>select<\/code> waiting on <code>&lt;-ctx.Done()<\/code> will block forever rather than never firing in a friendly way. If you have generic plumbing that assumes <code>Done()<\/code> is non-nil, test it.<\/p>\n<h2 id=\"afterfunc-for-the-things-that-dont-take-a-context\">AfterFunc, for the things that don&rsquo;t take a context<\/h2>\n<p>Plenty of real code predates <code>context<\/code>. <code>net.Conn<\/code> reads. <code>sync.Cond<\/code> waits. That vendor SDK with a <code>Close()<\/code> method and no <code>ctx<\/code> parameter anywhere. You cannot cancel these; you can only reach over and break them.<\/p>\n<p><code>context.AfterFunc<\/code> gives you the hook. It runs a function in its own goroutine once the context is canceled, and hands back a <code>stop<\/code> function to unregister it:<\/p>\n<pre><code class=\"language-go\">func readWithCancel(ctx context.Context, conn net.Conn, b []byte) (int, error) {\n    stopc := make(chan struct{})\n\n    stop := context.AfterFunc(ctx, func() {\n        conn.SetReadDeadline(time.Now())\n        close(stopc)\n    })\n\n    n, err := conn.Read(b)\n\n    if !stop() {\n        \/\/ The AfterFunc already fired. Wait for it, then reset.\n        &lt;-stopc\n        conn.SetReadDeadline(time.Time{})\n        return n, ctx.Err()\n    }\n    return n, err\n}\n<\/code><\/pre>\n<p>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.<\/p>\n<p>The <code>stop()<\/code> return value is the part I still have to re-read. <code>true<\/code> means you got there first and the function will never run. <code>false<\/code> means either it already started in its own goroutine, or stop was already called. <code>stop<\/code> does not wait for your function to finish, so if you care whether it completed, you coordinate yourself. Hence the <code>stopc<\/code> channel above.<\/p>\n<p>The same primitive merges two cancellation sources, which used to require a hand-rolled goroutine and a <code>select<\/code>:<\/p>\n<pre><code class=\"language-go\">func mergeCancel(ctx, cancelCtx context.Context) (context.Context, context.CancelFunc) {\n    ctx, cancel := context.WithCancelCause(ctx)\n    stop := context.AfterFunc(cancelCtx, func() {\n        cancel(context.Cause(cancelCtx))\n    })\n    return ctx, func() {\n        stop()\n        cancel(context.Canceled)\n    }\n}\n<\/code><\/pre>\n<h2 id=\"cancellation-you-can-actually-grep-for\">Cancellation you can actually grep for<\/h2>\n<p>The practical version of all this is boring and takes ten minutes. Declare your causes as package-level errors:<\/p>\n<pre><code class=\"language-go\">var (\n    errClientGone   = errors.New(&quot;client disconnected&quot;)\n    errBudgetSpent  = errors.New(&quot;query budget exhausted&quot;)\n    errShuttingDown = errors.New(&quot;server shutting down&quot;)\n)\n<\/code><\/pre>\n<p>Now they are greppable, they show up in autocomplete, and your tests can assert on them:<\/p>\n<pre><code class=\"language-go\">if !errors.Is(context.Cause(ctx), errBudgetSpent) {\n    t.Fatalf(&quot;expected budget cancellation, got %v&quot;, context.Cause(ctx))\n}\n<\/code><\/pre>\n<p>Cancellation causes are ordinary sentinel errors with a delivery mechanism attached, which is why they slot neatly into the <a href=\"https:\/\/abrarqasim.com\/blog\/go-error-handling-2026-the-patterns-i-actually-ship\" rel=\"noopener\">error handling patterns I ship in Go<\/a>. Same wrapping, same <code>errors.Is<\/code>, same conventions.<\/p>\n<p>One change in your logging middleware pays for the whole exercise: log <code>context.Cause(ctx)<\/code> next to <code>err<\/code>, not instead of it. Most of the backends I <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">build for clients<\/a> now do this by default, and the 2am pages have gotten noticeably shorter.<\/p>\n<h2 id=\"the-rules-that-didnt-change\">The rules that didn&rsquo;t change<\/h2>\n<p>None of the new functions rescue you from the old advice.<\/p>\n<p>Do not store a <code>Context<\/code> inside a struct. Pass it explicitly as the first parameter, named <code>ctx<\/code>. The Go team wrote up the <a href=\"https:\/\/go.dev\/blog\/context-and-structs\" rel=\"nofollow noopener\" target=\"_blank\">reasoning behind this<\/a> and it is still the right call.<\/p>\n<p>Still call your cancel functions on every path. <code>go vet<\/code> checks this, and failing to call cancel leaks the child context and its children until the parent goes away. <code>WithCancelCause<\/code> does not change that; it just means the call takes an argument.<\/p>\n<p><code>WithValue<\/code> 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.<\/p>\n<p>And if you are building pipelines of channels, the <a href=\"https:\/\/go.dev\/blog\/pipelines\" rel=\"nofollow noopener\" target=\"_blank\">pipelines article<\/a> remains the clearest explanation of using <code>Done<\/code> for cleanup that I know of.<\/p>\n<h2 id=\"what-i-would-do-this-week\">What I would do this week<\/h2>\n<p>Grep your handlers for <code>context.WithCancel(<\/code>. Pick the one attached to the incident you remember most vividly. Swap it to <code>WithCancelCause<\/code>, give it a named sentinel error, and log <code>context.Cause(ctx)<\/code> alongside <code>err<\/code> in whatever middleware writes your request logs.<\/p>\n<p>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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Golang context past WithCancel: how WithCancelCause, WithoutCancel and AfterFunc fixed three real production bugs for me, with before and after Go code.<\/p>\n","protected":false},"author":2,"featured_media":542,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Golang context past WithCancel: how WithCancelCause, WithoutCancel and AfterFunc fixed three real production bugs for me, with before and after Go code.","rank_math_focus_keyword":"golang context","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,159],"tags":[617,49,615,614,47,329,616],"class_list":["post-543","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-go","tag-afterfunc","tag-backend","tag-context-cancellation","tag-go-context","tag-golang","tag-observability","tag-withcancelcause"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/543","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=543"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/543\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/542"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=543"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=543"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=543"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}