{"id":232,"date":"2026-05-14T13:05:09","date_gmt":"2026-05-14T13:05:09","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/go-error-handling-patterns-i-actually-use-2026\/"},"modified":"2026-05-14T13:05:09","modified_gmt":"2026-05-14T13:05:09","slug":"go-error-handling-patterns-i-actually-use-2026","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-error-handling-patterns-i-actually-use-2026\/","title":{"rendered":"Go Error Handling: The Patterns That Stopped Me Hating if err != nil"},"content":{"rendered":"<p>Confession: for about a year, every time I opened a Go file at work I&rsquo;d silently grumble about <code>if err != nil<\/code>. I came from PHP and TypeScript where exceptions felt fine, and Go&rsquo;s explicit error returns looked like noise I had to step over to read the actual logic.<\/p>\n<p>I was wrong. Not about the noise (there is some), but about it being pointless. The reason I hated it was that I was treating errors as boilerplate to copy-paste. Once I picked up a handful of patterns from the <code>errors<\/code> package, <code>fmt.Errorf<\/code> with <code>%w<\/code>, and <code>errgroup<\/code>, the noise turned into something useful: a clear trail of what went wrong and where, without needing to grep stack traces in a JSON blob at 2am.<\/p>\n<p>This post is what I actually write at the keyboard in 2026, after a few years of shipping Go in production. Nothing fancy. The patterns are well known. The point is which ones I reach for first, which I dropped, and why.<\/p>\n<h2 id=\"the-old-way-i-was-writing-go-errors-and-why-it-fell-apart\">The old way I was writing Go errors (and why it fell apart)<\/h2>\n<p>Early on, all my error handling looked like this:<\/p>\n<pre><code class=\"language-go\">func loadUser(id string) (*User, error) {\n    row, err := db.Query(&quot;SELECT ... WHERE id = ?&quot;, id)\n    if err != nil {\n        return nil, errors.New(&quot;could not load user&quot;)\n    }\n    \/\/ ...\n}\n<\/code><\/pre>\n<p>That&rsquo;s the trap. <code>errors.New(\"could not load user\")<\/code> discards the original <code>err<\/code>. By the time the message bubbles up to my logger I&rsquo;ve lost the actual cause. Was it a network blip? A bad SQL query? A typo in a column name? I&rsquo;d reproduce the bug locally, see the real database error, and curse past me.<\/p>\n<p>The fix is <code>fmt.Errorf<\/code> with the <code>%w<\/code> verb, which has been in the standard library since Go 1.13. It wraps the original error so I can still unwrap it later:<\/p>\n<pre><code class=\"language-go\">func loadUser(id string) (*User, error) {\n    row, err := db.Query(&quot;SELECT ... WHERE id = ?&quot;, id)\n    if err != nil {\n        return nil, fmt.Errorf(&quot;loadUser %s: query: %w&quot;, id, err)\n    }\n    \/\/ ...\n}\n<\/code><\/pre>\n<p>Two things changed. I keep the original error attached via <code>%w<\/code>. And I include the input (<code>id<\/code>) and the operation (<code>query<\/code>) in the message, so when I see <code>loadUser 9f3a: query: connection refused<\/code> in logs, I know exactly where to look. The <a href=\"https:\/\/go.dev\/blog\/go1.13-errors\" rel=\"nofollow noopener\" target=\"_blank\">Go team&rsquo;s post on Go 1.13 errors<\/a> walks through the design, and it&rsquo;s worth reading once even if you&rsquo;ve been writing Go for years.<\/p>\n<h2 id=\"stop-reaching-for-errorsnew-in-chained-code-paths\">Stop reaching for <code>errors.New<\/code> in chained code paths<\/h2>\n<p>Related habit I had to break: using <code>errors.New<\/code> everywhere. <code>errors.New<\/code> is fine for sentinel values (more on those in a second), but if you&rsquo;re wrapping a caller&rsquo;s mistake you want <code>fmt.Errorf(\"...: %w\", err)<\/code> every time.<\/p>\n<p>The mental model that finally clicked: <code>errors.New<\/code> is for inventing a brand new error. <code>fmt.Errorf<\/code> with <code>%w<\/code> is for handing off an existing error with more context bolted on. If I&rsquo;m somewhere deep in a call stack and a lower function already failed, I never invent. I always wrap.<\/p>\n<p>The bonus is that wrapped errors compose. By the time the error reaches my HTTP handler it reads like a small breadcrumb trail:<\/p>\n<pre><code>loadUser 9f3a: query: dial tcp 10.0.0.5:5432: i\/o timeout\n<\/code><\/pre>\n<p>That single line tells me which function, which input, which step inside that function, and what the OS actually returned. No stack trace required.<\/p>\n<h2 id=\"sentinel-errors-versus-typed-errors\">Sentinel errors versus typed errors<\/h2>\n<p>This is the part where I confused myself for months. Go gives you two ways to express &ldquo;this is a known kind of failure&rdquo;.<\/p>\n<p>Sentinel errors are package-level variables:<\/p>\n<pre><code class=\"language-go\">var ErrUserNotFound = errors.New(&quot;user not found&quot;)\n\nfunc loadUser(id string) (*User, error) {\n    row, err := db.Query(...)\n    if err != nil {\n        if errors.Is(err, sql.ErrNoRows) {\n            return nil, fmt.Errorf(&quot;loadUser %s: %w&quot;, id, ErrUserNotFound)\n        }\n        return nil, fmt.Errorf(&quot;loadUser %s: query: %w&quot;, id, err)\n    }\n    \/\/ ...\n}\n<\/code><\/pre>\n<p>Typed errors are structs with extra fields:<\/p>\n<pre><code class=\"language-go\">type ValidationError struct {\n    Field   string\n    Message string\n}\n\nfunc (e *ValidationError) Error() string {\n    return fmt.Sprintf(&quot;validation: %s: %s&quot;, e.Field, e.Message)\n}\n<\/code><\/pre>\n<p>I default to sentinels when the caller only needs to know &ldquo;yes or no, did this thing happen&rdquo;. <code>ErrUserNotFound<\/code> is a perfect fit because the HTTP handler doesn&rsquo;t care <em>why<\/em> the user wasn&rsquo;t found, just that they weren&rsquo;t. I reach for typed errors when the caller needs structured data, like a field name from a validation failure or a retryable flag from a transient backend.<\/p>\n<p>One rule I stick to: if I add a new sentinel, it lives next to the function that returns it, not in a shared <code>errors.go<\/code> file at the root of the module. That convention keeps the surface area small.<\/p>\n<h2 id=\"errorsis-versus-errorsas-the-thing-i-got-wrong-for-months\"><code>errors.Is<\/code> versus <code>errors.As<\/code>: the thing I got wrong for months<\/h2>\n<p>I confused these two for too long, so writing it down for past me.<\/p>\n<p>Use <code>errors.Is(err, target)<\/code> when target is a sentinel value:<\/p>\n<pre><code class=\"language-go\">if errors.Is(err, ErrUserNotFound) {\n    return http.StatusNotFound\n}\n<\/code><\/pre>\n<p>Use <code>errors.As(err, &amp;target)<\/code> when target is a typed error and you need to read its fields:<\/p>\n<pre><code class=\"language-go\">var verr *ValidationError\nif errors.As(err, &amp;verr) {\n    return verr.Field, verr.Message\n}\n<\/code><\/pre>\n<p>The trick that finally made it stick: <code>Is<\/code> answers a yes\/no question. <code>As<\/code> extracts data. If I&rsquo;m asking &ldquo;is this the kind of error where I should retry?&rdquo;, that&rsquo;s <code>Is<\/code>. If I&rsquo;m asking &ldquo;what field failed validation?&rdquo;, that&rsquo;s <code>As<\/code>. The official <a href=\"https:\/\/pkg.go.dev\/errors\" rel=\"nofollow noopener\" target=\"_blank\">errors package docs<\/a> lay it out, but seeing it twice in the codebase you wrote yourself is what cements it.<\/p>\n<h2 id=\"errgroup-for-parallel-work-that-can-fail\"><code>errgroup<\/code> for parallel work that can fail<\/h2>\n<p><code>sync.WaitGroup<\/code> works fine for fire-and-forget goroutines, but the moment any of those goroutines can return an error you want to surface, you should be reaching for <code>golang.org\/x\/sync\/errgroup<\/code> instead. I&rsquo;ve watched too many teams write a custom <code>errors []error<\/code> slice with a mutex when this exists.<\/p>\n<pre><code class=\"language-go\">import &quot;golang.org\/x\/sync\/errgroup&quot;\n\nfunc fetchAll(ctx context.Context, ids []string) ([]*User, error) {\n    g, ctx := errgroup.WithContext(ctx)\n    users := make([]*User, len(ids))\n\n    for i, id := range ids {\n        i, id := i, id \/\/ capture (pre-1.22 habit, still doesn't hurt)\n        g.Go(func() error {\n            u, err := loadUser(ctx, id)\n            if err != nil {\n                return fmt.Errorf(&quot;fetch %s: %w&quot;, id, err)\n            }\n            users[i] = u\n            return nil\n        })\n    }\n\n    if err := g.Wait(); err != nil {\n        return nil, err\n    }\n    return users, nil\n}\n<\/code><\/pre>\n<p>What I love: as soon as one goroutine returns an error, the context cancels, so all the other in-flight calls bail out instead of pointlessly finishing. That alone is worth the import. The <a href=\"https:\/\/pkg.go.dev\/golang.org\/x\/sync\/errgroup\" rel=\"nofollow noopener\" target=\"_blank\">errgroup package docs<\/a> cover the rest, including <code>SetLimit<\/code> for bounding concurrency. If you want the wider concurrency picture, I went deeper in <a href=\"https:\/\/abrarqasim.com\/blog\/go-concurrency-patterns-what-i-actually-ship\" rel=\"noopener\">my piece on Go concurrency patterns I actually ship<\/a>, which pairs nicely with this one.<\/p>\n<h2 id=\"errors-arent-just-for-humans-hook-them-into-your-logs\">Errors aren&rsquo;t just for humans: hook them into your logs<\/h2>\n<p>Last habit that paid off. When my error messages started including the input and the operation (<code>loadUser 9f3a: query: ...<\/code>), my logs got easier to grep. But the real step up was teaching my logger to extract structured fields from those errors.<\/p>\n<p>If you&rsquo;re using <code>log\/slog<\/code> (and you should be, it&rsquo;s in the standard library now), you can pass the error directly and the logger handles the rest:<\/p>\n<pre><code class=\"language-go\">logger.Error(&quot;load failed&quot;,\n    slog.String(&quot;user_id&quot;, id),\n    slog.Any(&quot;error&quot;, err))\n<\/code><\/pre>\n<p>For typed errors, I write a <code>LogValue()<\/code> method so slog pulls out <code>field<\/code>, <code>message<\/code>, <code>retryable<\/code> automatically as JSON keys. The result is logs I can query by error type in Loki or Datadog without parsing strings, which is the kind of thing past me would have called overkill and present me considers obvious.<\/p>\n<p>I cover the broader habit (treat errors as data the rest of your stack can use, not just strings to print) across other languages in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my backend work<\/a>.<\/p>\n<h2 id=\"the-one-week-action\">The one-week action<\/h2>\n<p>If you&rsquo;re rewriting Go errors this week, the change that pays back fastest: open the file you most recently touched, search for <code>errors.New(<\/code>, and check each one. If you&rsquo;re inside a function that already received an <code>err<\/code> from somewhere else, change it to <code>fmt.Errorf(\"context: %w\", err)<\/code>. That&rsquo;s it. One commit. You&rsquo;ll get the next bug report and the message will be readable, and you&rsquo;ll wonder why you didn&rsquo;t do it sooner.<\/p>\n<p>That&rsquo;s roughly where I was a couple of years ago, grumbling at <code>if err != nil<\/code> and copy-pasting the same useless wrapping. The patterns above aren&rsquo;t fancy. They&rsquo;re just the small set I keep reaching for, and they make Go errors feel like a feature instead of a tax.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The Go error patterns I actually use in production: fmt.Errorf %w wrapping, sentinel vs typed errors, errors.Is vs errors.As, and errgroup at scale.<\/p>\n","protected":false},"author":2,"featured_media":231,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The Go error patterns I actually use in production: fmt.Errorf %w wrapping, sentinel vs typed errors, errors.Is vs errors.As, and errgroup at scale.","rank_math_focus_keyword":"golang error handling","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147],"tags":[49,267,48,268,46,47],"class_list":["post-232","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","tag-backend","tag-errgroup","tag-error-handling","tag-errors","tag-go","tag-golang"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/232","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=232"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/232\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/231"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=232"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=232"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=232"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}