{"id":466,"date":"2026-07-16T05:03:42","date_gmt":"2026-07-16T05:03:42","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/go-error-handling-2026-the-patterns-i-actually-ship\/"},"modified":"2026-07-16T05:03:42","modified_gmt":"2026-07-16T05:03:42","slug":"go-error-handling-2026-the-patterns-i-actually-ship","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-error-handling-2026-the-patterns-i-actually-ship\/","title":{"rendered":"Go Error Handling in 2026: The Patterns I Actually Ship"},"content":{"rendered":"<p>Confession: I spent two hours last Tuesday tracking down a bug that turned out to be a nil pointer three call sites down from where the error surfaced. My stack trace was fine. My <em>error wrapping<\/em> was garbage. The message printed <code>sql: no rows in result set<\/code> at the top of a handler and I couldn&rsquo;t tell which of four queries fired it.<\/p>\n<p>I&rsquo;ve been shipping Go for years and my error handling still leaks. So over the past few months I&rsquo;ve been tightening it. Cutting the <code>if err != nil<\/code> ceremony where it doesn&rsquo;t earn its keep, wrapping consistently, and finally getting <code>errors.Is<\/code> and <code>errors.As<\/code> right in my head. This post is what I actually run in production in 2026, warts included.<\/p>\n<p>Short version for the impatient: wrap every error at every boundary with <code>%w<\/code>, use typed errors for anything a caller might branch on, and stop logging errors before you return them. If you want the code, keep reading.<\/p>\n<h2 id=\"the-if-err-nil-ceremony-i-stopped-writing-everywhere\">The <code>if err != nil<\/code> ceremony I stopped writing everywhere<\/h2>\n<p>The classic Go complaint is that half your code is <code>if err != nil { return err }<\/code>. I used to defend that. I don&rsquo;t anymore, and it&rsquo;s not because the pattern is wrong. It&rsquo;s because a lot of those returns weren&rsquo;t doing anything useful.<\/p>\n<p>Here&rsquo;s what most of my HTTP handlers looked like a year ago:<\/p>\n<pre><code class=\"language-go\">func getUser(w http.ResponseWriter, r *http.Request) {\n    id, err := strconv.Atoi(r.PathValue(&quot;id&quot;))\n    if err != nil {\n        http.Error(w, err.Error(), 400)\n        return\n    }\n    user, err := db.GetUser(r.Context(), id)\n    if err != nil {\n        http.Error(w, err.Error(), 500)\n        return\n    }\n    if err := json.NewEncoder(w).Encode(user); err != nil {\n        log.Println(err)\n    }\n}\n<\/code><\/pre>\n<p>Every error handled the same generic way. No wrapping. No context. Just a raw message to the client and a naked <code>log.Println<\/code> if the encode fails.<\/p>\n<p>Now I lean on a tiny helper per package that centralizes the pattern:<\/p>\n<pre><code class=\"language-go\">func writeErr(w http.ResponseWriter, r *http.Request, err error) {\n    var httpErr *HTTPError\n    if errors.As(err, &amp;httpErr) {\n        http.Error(w, httpErr.Message, httpErr.Status)\n        return\n    }\n    slog.ErrorContext(r.Context(), &quot;handler error&quot;, &quot;err&quot;, err)\n    http.Error(w, &quot;internal error&quot;, 500)\n}\n<\/code><\/pre>\n<p>The handlers get thinner and the error path always has structure. That&rsquo;s what I care about. Not deleting <code>if err != nil<\/code> for the sake of it.<\/p>\n<h2 id=\"wrapping-with-w-what-actually-changed-my-debugging\">Wrapping with <code>%w<\/code>: what actually changed my debugging<\/h2>\n<p><code>fmt.Errorf(\"...: %w\", err)<\/code> sounds boring. It&rsquo;s the single change that dropped my average time-to-root-cause the most in the last year.<\/p>\n<p>The rule I follow: <strong>every function that returns an error from another function wraps it with <code>%w<\/code> and adds one sentence of context<\/strong>. Not two. Not zero. One.<\/p>\n<pre><code class=\"language-go\">func loadInvoice(ctx context.Context, id int64) (*Invoice, error) {\n    inv, err := db.getInvoice(ctx, id)\n    if err != nil {\n        return nil, fmt.Errorf(&quot;load invoice %d: %w&quot;, id, err)\n    }\n    items, err := db.getItems(ctx, id)\n    if err != nil {\n        return nil, fmt.Errorf(&quot;load items for invoice %d: %w&quot;, id, err)\n    }\n    inv.Items = items\n    return inv, nil\n}\n<\/code><\/pre>\n<p>When something fails four layers deep, I now get a message like <code>handle checkout POST: load invoice 4218: get items for invoice 4218: sql: no rows in result set<\/code>. The chain reads top-down like a Russian doll and I know exactly which query blew up.<\/p>\n<p>The Go blog&rsquo;s <a href=\"https:\/\/go.dev\/blog\/go1.13-errors\" rel=\"nofollow noopener\" target=\"_blank\">working with errors in Go 1.13 post<\/a> is still the canonical reference. If you&rsquo;re reading it for the first time, read it twice. The <code>%w<\/code> verb changed the language more than most people realize.<\/p>\n<h2 id=\"errorsis-vs-errorsas-the-split-i-got-wrong-for-a-year\"><code>errors.Is<\/code> vs <code>errors.As<\/code>: the split I got wrong for a year<\/h2>\n<p>I mixed these up embarrassingly often. Here&rsquo;s the version that finally stuck in my head:<\/p>\n<ul>\n<li><code>errors.Is(err, target)<\/code>: is this error <em>equal to<\/em> (or wrapping) a specific sentinel value? Use it for <code>sql.ErrNoRows<\/code>, <code>context.Canceled<\/code>, <code>io.EOF<\/code>. It&rsquo;s a value comparison.<\/li>\n<li><code>errors.As(err, &amp;target)<\/code>: can this error be <em>unwrapped into<\/em> a specific type? Use it for custom struct errors where you need fields off the target. It&rsquo;s a type assertion.<\/li>\n<\/ul>\n<pre><code class=\"language-go\">inv, err := loadInvoice(ctx, id)\nif err != nil {\n    if errors.Is(err, sql.ErrNoRows) {\n        return c.JSON(404, &quot;not found&quot;)\n    }\n    var vErr *ValidationError\n    if errors.As(err, &amp;vErr) {\n        return c.JSON(400, map[string]any{\n            &quot;field&quot;: vErr.Field,\n            &quot;reason&quot;: vErr.Reason,\n        })\n    }\n    return c.JSON(500, &quot;internal error&quot;)\n}\n<\/code><\/pre>\n<p>The mental model: <code>Is<\/code> for equality, <code>As<\/code> for extraction. If you find yourself doing <code>err.Error() == \"some string\"<\/code> anywhere, that&rsquo;s the signal you needed one of these two. The <a href=\"https:\/\/pkg.go.dev\/errors\" rel=\"nofollow noopener\" target=\"_blank\">errors package docs<\/a> spell it out but the naming still trips me up occasionally.<\/p>\n<h2 id=\"sentinel-errors-vs-typed-errors-when-each-earns-its-keep\">Sentinel errors vs typed errors: when each earns its keep<\/h2>\n<p>Sentinel errors are package-level <code>var<\/code> values like <code>io.EOF<\/code> or <code>sql.ErrNoRows<\/code>. Typed errors are structs that implement <code>error<\/code>. Both work. They&rsquo;re for different things.<\/p>\n<p><strong>Reach for a sentinel<\/strong> when there&rsquo;s exactly one thing that can go wrong and no extra data to carry. <code>ErrNotFound<\/code>, <code>ErrRateLimited<\/code>, <code>ErrShuttingDown<\/code>. Callers check with <code>errors.Is<\/code>.<\/p>\n<p><strong>Reach for a typed error<\/strong> when there&rsquo;s structured data the caller might want. A validation error that carries a field name. An HTTP error that carries a status code. A retryable error that carries a next-attempt delay.<\/p>\n<pre><code class=\"language-go\">type ValidationError struct {\n    Field  string\n    Reason string\n}\n\nfunc (e *ValidationError) Error() string {\n    return fmt.Sprintf(&quot;validation: %s: %s&quot;, e.Field, e.Reason)\n}\n\ntype RetryableError struct {\n    Err   error\n    After time.Duration\n}\n\nfunc (e *RetryableError) Error() string { return e.Err.Error() }\nfunc (e *RetryableError) Unwrap() error { return e.Err }\n<\/code><\/pre>\n<p>The <code>Unwrap<\/code> method is what makes <code>errors.Is<\/code> and <code>errors.As<\/code> walk through your custom types to the underlying cause. If you forget it, chains break silently and you&rsquo;ll spend an afternoon wondering why your sentinel checks stopped matching.<\/p>\n<p>One trap I fell into: don&rsquo;t export sentinel errors from packages where callers won&rsquo;t need to branch on them. Once they&rsquo;re exported, they&rsquo;re API forever. If nobody&rsquo;s calling <code>errors.Is<\/code> on your <code>ErrConnectionRefused<\/code>, keep it internal and let it wrap up as a generic 500.<\/p>\n<h2 id=\"structured-errors-slog-the-pattern-i-ship-now\">Structured errors + slog: the pattern I ship now<\/h2>\n<p>This one plugs into what I wrote about in my <a href=\"https:\/\/abrarqasim.com\/blog\/golang-slog-in-production-the-structured-logging-i-actually-ship\/\" rel=\"noopener\">structured logging with slog post<\/a>. The two patterns lock together and I&rsquo;d struggle to give up either.<\/p>\n<p>The setup: when a handler catches an error, log it once at the top with the whole chain and a request-scoped attributes bag. Everything below just returns and wraps. No <code>log.Println<\/code> sprinkled through business code.<\/p>\n<pre><code class=\"language-go\">func writeErr(w http.ResponseWriter, r *http.Request, err error) {\n    logger := slog.With(\n        &quot;method&quot;, r.Method,\n        &quot;path&quot;, r.URL.Path,\n        &quot;request_id&quot;, middleware.RequestID(r.Context()),\n    )\n    var httpErr *HTTPError\n    if errors.As(err, &amp;httpErr) {\n        logger.WarnContext(r.Context(), &quot;client error&quot;,\n            &quot;status&quot;, httpErr.Status, &quot;err&quot;, err)\n        http.Error(w, httpErr.Message, httpErr.Status)\n        return\n    }\n    logger.ErrorContext(r.Context(), &quot;server error&quot;, &quot;err&quot;, err)\n    http.Error(w, &quot;internal error&quot;, 500)\n}\n<\/code><\/pre>\n<p>When slog&rsquo;s JSON handler flattens the wrapped <code>err<\/code>, the whole chain shows up in one field, filterable in Grafana or Datadog with no regex. That&rsquo;s the win. One log line per failed request, with the full trail. My previous setup had errors scattered across five different <code>log<\/code> calls and I&rsquo;d have to correlate them manually.<\/p>\n<p>If you&rsquo;re pairing this with concurrent code, I cover the errgroup side of the story in my <a href=\"https:\/\/abrarqasim.com\/blog\/golang-concurrency-patterns-2026-errgroup-context-i-actually-ship\/\" rel=\"noopener\">Go concurrency patterns post<\/a>. The same wrapping rules apply, just with an extra <code>g.Wait()<\/code> at the end.<\/p>\n<h2 id=\"the-one-anti-pattern-i-still-see-everywhere\">The one anti-pattern I still see everywhere<\/h2>\n<p>Log-and-return. It looks harmless:<\/p>\n<pre><code class=\"language-go\">if err != nil {\n    log.Println(&quot;failed to load user:&quot;, err)\n    return err\n}\n<\/code><\/pre>\n<p>Now every layer that touches this error logs it. Your logs fill with the same error six times, at six different stack frames, and the actual root cause is buried in duplicates. Debugging a production incident becomes a puzzle of deduping timestamps.<\/p>\n<p>Pick one: log it or return it. Never both. My rule is that only the outermost layer that decides how to respond to the user (usually the HTTP handler or the CLI command) logs. Everything below wraps and returns silently. If you need extra context in the log, add it to the wrapped error, not to a parallel log line.<\/p>\n<p>The same rule applies to <code>fmt.Println<\/code> for debugging that you swore you&rsquo;d delete before shipping. You know the one. Delete it. Add a real slog line with structured attributes if you actually need the visibility.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Grep your repo for <code>log.Println(err)<\/code> and <code>log.Printf(...err...)<\/code>. For each one, decide: is this the outermost layer? If yes, upgrade it to <code>slog.ErrorContext<\/code> with the request attributes. If no, delete the log and make sure the error is wrapped with <code>%w<\/code> and one sentence of context. That single pass usually catches 80% of the debugging pain in a mid-sized Go codebase.<\/p>\n<p>If you want to see how I think about the broader engineering practice (architecture, observability, the boring bits that make services survive production), I keep a running set of case studies on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio work page<\/a>. Not a sales pitch, just what I&rsquo;ve been shipping.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The Go error handling patterns I actually ship in 2026: wrapping with %w, errors.Is vs errors.As, and killing the log-and-return anti-pattern.<\/p>\n","protected":false},"author":2,"featured_media":465,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The Go error handling patterns I actually ship in 2026: wrapping with %w, errors.Is vs errors.As, and killing the log-and-return anti-pattern.","rank_math_focus_keyword":"go error handling","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,159],"tags":[519,49,380,518,517,516,46,47,329],"class_list":["post-466","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-go","tag-w","tag-backend","tag-error-handling-2","tag-error-wrapping","tag-errors-as","tag-errors-is","tag-go","tag-golang","tag-observability"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/466","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=466"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/466\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/465"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=466"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=466"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=466"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}