{"id":340,"date":"2026-06-18T13:01:31","date_gmt":"2026-06-18T13:01:31","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/go-error-handling-2026-patterns-i-actually-reach-for\/"},"modified":"2026-06-18T13:01:31","modified_gmt":"2026-06-18T13:01:31","slug":"go-error-handling-2026-patterns-i-actually-reach-for","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-error-handling-2026-patterns-i-actually-reach-for\/","title":{"rendered":"Go Error Handling: The Patterns I Actually Reach For"},"content":{"rendered":"<p>I lost an afternoon last week to a single line of code. The line was <code>return err<\/code>. Not a panic, not a stack trace, just a 500 in production and a log entry that said <code>record not found<\/code> with nothing else. Which record? Which of the four database calls in that handler? I&rsquo;d written the thing myself eight months earlier and felt pretty good about how clean it looked. Past me had left present me a mystery with no clues.<\/p>\n<p>Go&rsquo;s error handling gets mocked a lot. The <code>if err != nil<\/code> block is basically a meme now. But after years of shipping Go services, my honest opinion is that the verbosity isn&rsquo;t the real problem. The problem is that most of us treat an error as something to pass upward instead of something to describe. I did this for a long time. This post is the small set of patterns I actually use now, with the before and after code that changed how I write handlers.<\/p>\n<h2 id=\"the-if-err-nil-block-isnt-the-enemy\">The if err != nil block isn&rsquo;t the enemy<\/h2>\n<p>Everyone wants to talk about how noisy Go errors are. Three extra lines after every call, over and over. I used to mind this. I don&rsquo;t anymore.<\/p>\n<p>Here&rsquo;s the thing I missed for too long: those three lines are a place to add information, and most code throws that place away. Look at the version I used to write:<\/p>\n<pre><code class=\"language-go\">func getUser(id int) (*User, error) {\n    row, err := db.Query(id)\n    if err != nil {\n        return nil, err\n    }\n    return parse(row)\n}\n<\/code><\/pre>\n<p>That <code>return nil, err<\/code> is the bare minimum. It compiles, it passes review, and it tells you nothing when it fails. The block isn&rsquo;t the problem. What I put inside it was.<\/p>\n<h2 id=\"wrap-errors-with-w-so-you-can-read-your-logs\">Wrap errors with %w so you can read your logs<\/h2>\n<p>The fix that saved me the most time is also the smallest. Go 1.13 added the <code>%w<\/code> verb to <code>fmt.Errorf<\/code>, which wraps an error while keeping the original reachable. The <a href=\"https:\/\/go.dev\/blog\/go1.13-errors\" rel=\"nofollow noopener\" target=\"_blank\">Go team wrote it up on the official blog<\/a>, and it&rsquo;s the one feature I&rsquo;d back-port to every codebase if I could.<\/p>\n<p>Same function, one change:<\/p>\n<pre><code class=\"language-go\">func getUser(id int) (*User, error) {\n    row, err := db.Query(id)\n    if err != nil {\n        return nil, fmt.Errorf(&quot;getUser %d: %w&quot;, id, err)\n    }\n    return parse(row)\n}\n<\/code><\/pre>\n<p>Now the error that lands in my logs reads like a trail: <code>getUser 4127: query timeout<\/code>. If three layers each add their bit of context, I get a sentence that tells me where the request went and where it died. I stopped reaching for a debugger on half my production issues once I started doing this everywhere.<\/p>\n<h2 id=\"errorsis-and-errorsas-instead-of-string-matching\">errors.Is and errors.As instead of string matching<\/h2>\n<p>Here&rsquo;s a confession. For about two weeks early on, I checked error types like this:<\/p>\n<pre><code class=\"language-go\">if strings.Contains(err.Error(), &quot;no rows&quot;) {\n    return notFound()\n}\n<\/code><\/pre>\n<p>It worked until someone reworded an error string and my check silently stopped matching. Wrapping breaks this approach completely, since the wrapped message has extra text around the original.<\/p>\n<p>The <code>errors<\/code> package has the right tools for this, documented in the <a href=\"https:\/\/pkg.go.dev\/errors\" rel=\"nofollow noopener\" target=\"_blank\">standard library reference<\/a>. <code>errors.Is<\/code> walks the whole wrapped chain looking for a specific value:<\/p>\n<pre><code class=\"language-go\">row, err := db.Query(id)\nif errors.Is(err, sql.ErrNoRows) {\n    return nil, ErrUserNotFound\n}\n<\/code><\/pre>\n<p>And <code>errors.As<\/code> pulls a specific error type out of the chain so you can read its fields:<\/p>\n<pre><code class=\"language-go\">var pqErr *pq.Error\nif errors.As(err, &amp;pqErr) {\n    log.Printf(&quot;postgres code: %s&quot;, pqErr.Code)\n}\n<\/code><\/pre>\n<p>Both of these keep working no matter how many layers of <code>%w<\/code> sit between the call and the check. That&rsquo;s the payoff for wrapping properly.<\/p>\n<h2 id=\"sentinel-errors-and-custom-types-and-when-each-earns-its-place\">Sentinel errors and custom types, and when each earns its place<\/h2>\n<p>A sentinel error is just a package-level value you compare against:<\/p>\n<pre><code class=\"language-go\">var ErrUserNotFound = errors.New(&quot;user not found&quot;)\n<\/code><\/pre>\n<p>I reach for these when callers need to branch on a known condition and don&rsquo;t need extra detail. A login handler that wants to return 404 versus 500 only needs to know &ldquo;was it not found.&rdquo; <code>errors.Is(err, ErrUserNotFound)<\/code> answers that and nothing more.<\/p>\n<p>When callers need data about the failure, I write a small type instead:<\/p>\n<pre><code class=\"language-go\">type ValidationError struct {\n    Field string\n    Msg   string\n}\n\nfunc (e *ValidationError) Error() string {\n    return fmt.Sprintf(&quot;%s: %s&quot;, e.Field, e.Msg)\n}\n<\/code><\/pre>\n<p>Now the API layer can pull the field name out with <code>errors.As<\/code> and build a useful response. The rule I follow: sentinel for &ldquo;which case,&rdquo; custom type for &ldquo;which case plus details.&rdquo; I don&rsquo;t add a custom type until I actually need the fields, because a type nobody inspects is just a heavier <code>errors.New<\/code>.<\/p>\n<h2 id=\"errorsjoin-for-the-times-one-error-isnt-enough\">errors.Join for the times one error isn&rsquo;t enough<\/h2>\n<p>Sometimes a function fails in more than one way at once. Validating a form is the obvious case. I used to concatenate strings by hand, which was ugly and lost the individual errors. Go 1.20 added <code>errors.Join<\/code>, covered in the <a href=\"https:\/\/go.dev\/doc\/go1.20\" rel=\"nofollow noopener\" target=\"_blank\">release notes<\/a>, and it cleaned this up:<\/p>\n<pre><code class=\"language-go\">func validate(f Form) error {\n    var errs []error\n    if f.Email == &quot;&quot; {\n        errs = append(errs, errors.New(&quot;email required&quot;))\n    }\n    if f.Age &lt; 18 {\n        errs = append(errs, errors.New(&quot;must be 18+&quot;))\n    }\n    return errors.Join(errs...)\n}\n<\/code><\/pre>\n<p>The joined error prints each problem on its own line, and <code>errors.Is<\/code> still works against any of them. If the slice is empty, <code>Join<\/code> returns nil, so the happy path stays clean with no special case.<\/p>\n<h2 id=\"where-i-stop-wrapping\">Where I stop wrapping<\/h2>\n<p>Wrapping has a cost the tutorials skip. When you wrap an error with <code>%w<\/code>, you expose it to your callers, and they can start depending on it. The Go team&rsquo;s own <a href=\"https:\/\/go.dev\/wiki\/ErrorValueFAQ\" rel=\"nofollow noopener\" target=\"_blank\">error value FAQ<\/a> makes this point: once an error is part of your public surface, changing it can break people. So inside a package, I wrap freely. At a public API boundary I think harder, and sometimes I deliberately return a fresh sentinel instead of leaking a database driver&rsquo;s error to the outside world.<\/p>\n<p>The other place I hold back is logging. If I&rsquo;ve already logged an error with full context, I don&rsquo;t also wrap and return it up the stack to be logged again. That&rsquo;s how you get the same failure printed five times. I sort out logging boundaries the same way I sorted out structured logs, which I wrote about in <a href=\"https:\/\/abrarqasim.com\/blog\/go-slog-structured-logging-i-stopped-hand-rolling\" rel=\"noopener\">my post on Go&rsquo;s slog package<\/a>. Decide who logs, and let everyone else just return.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Open your codebase and grep for <code>return err<\/code>. Not to fix every one, that&rsquo;s a waste of a weekend. Find the five hottest paths, the handlers that page you at 3am, and give each <code>return err<\/code> a <code>%w<\/code> wrap with the function name and the key identifier. That alone turns most of your mystery logs into readable sentences. If you want to see how I structure the services I build around this, it&rsquo;s in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my work<\/a>.<\/p>\n<p>Go errors aren&rsquo;t elegant. I&rsquo;ve made peace with that. But treated as a place to write down what happened, the verbose little blocks earn their keep.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The Go error handling patterns I actually use in production: wrapping with %w, errors.Is and errors.As, sentinel errors, and errors.Join, with code.<\/p>\n","protected":false},"author":2,"featured_media":339,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The Go error handling patterns I actually use in production: wrapping with %w, errors.Is and errors.As, sentinel errors, and errors.Join, with code.","rank_math_focus_keyword":"golang error handling","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[159,45],"tags":[49,380,46,381,47],"class_list":["post-340","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-go","category-programming","tag-backend","tag-error-handling-2","tag-go","tag-go-errors","tag-golang"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/340","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=340"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/340\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/339"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=340"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=340"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=340"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}