{"id":75,"date":"2026-04-15T05:53:48","date_gmt":"2026-04-15T05:53:48","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/?p=75"},"modified":"2026-04-15T05:53:48","modified_gmt":"2026-04-15T05:53:48","slug":"go-error-handling-patterns-that-actually-help","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-error-handling-patterns-that-actually-help\/","title":{"rendered":"Go error handling patterns that actually help"},"content":{"rendered":"<p>I wrote my first Go service about three years ago and my main takeaway was: I have never typed <code>if err != nil<\/code> so many times in my life. It felt like half the codebase was error checks. I nearly went back to Python out of sheer frustration.<\/p>\n<p>Three years later, I write Go full time and I still type <code>if err != nil<\/code> constantly. The difference is I stopped fighting it and started building patterns around it that make the repetition less painful and the errors themselves more useful. This post is those patterns.<\/p>\n<h2 id=\"the-problem-isnt-the-syntax-its-the-context\">The problem isn&rsquo;t the syntax, it&rsquo;s the context<\/h2>\n<p>Most complaints about Go error handling focus on the verbosity. Fair enough. But the real issue I kept hitting wasn&rsquo;t the number of lines, it was that my errors were useless when they reached the top of the call stack. Something like <code>open config.json: no such file or directory<\/code> is fine. But <code>invalid input<\/code> with no indication of which function, which layer, or which input? That&rsquo;s the actual problem.<\/p>\n<p>Go&rsquo;s error handling is verbose by design because it forces you to make a decision at every call site. The question is whether you make a good decision or just pass the error along unchanged. Most tutorials teach you to do the latter, and that&rsquo;s where things go wrong.<\/p>\n<h2 id=\"error-wrapping-with-w-changed-everything\">Error wrapping with %w changed everything<\/h2>\n<p><img decoding=\"async\" alt=\"Go error handling patterns that actually help\" src=\"https:\/\/abrarqasim.com\/blog\/wp-content\/uploads\/2026\/04\/go-error-handling-patterns-that-actually-help-inline-1776171675.png\"><\/p>\n<p>Before Go 1.13, you had two bad options: return the error as-is (losing context) or format a new error string with <code>fmt.Errorf<\/code> (losing the original error). The <code>%w<\/code> verb fixed this by letting you add context while preserving the original:<\/p>\n<pre><code class=\"language-go\">\/\/ Bad: caller has no idea where this came from\nfunc LoadConfig(path string) (*Config, error) {\n    data, err := os.ReadFile(path)\n    if err != nil {\n        return nil, err\n    }\n    var cfg Config\n    if err := json.Unmarshal(data, &amp;cfg); err != nil {\n        return nil, err\n    }\n    return &amp;cfg, nil\n}\n<\/code><\/pre>\n<pre><code class=\"language-go\">\/\/ Better: every error carries its origin\nfunc LoadConfig(path string) (*Config, error) {\n    data, err := os.ReadFile(path)\n    if err != nil {\n        return nil, fmt.Errorf(&quot;load config: read %s: %w&quot;, path, err)\n    }\n    var cfg Config\n    if err := json.Unmarshal(data, &amp;cfg); err != nil {\n        return nil, fmt.Errorf(&quot;load config: parse json: %w&quot;, err)\n    }\n    return &amp;cfg, nil\n}\n<\/code><\/pre>\n<p>Now when this fails, you get <code>load config: read \/etc\/myapp\/config.json: permission denied<\/code> instead of just <code>permission denied<\/code>. The caller can still use <code>errors.Is<\/code> and <code>errors.As<\/code> to check the underlying cause. You get context and type safety.<\/p>\n<p>The pattern I settled on: prefix with the function name and a short description of what step failed. Keep it lowercase, no punctuation at the end. It reads naturally when errors chain: <code>start server: load config: read \/etc\/myapp\/config.json: permission denied<\/code>.<\/p>\n<h2 id=\"sentinel-errors-for-things-callers-need-to-branch-on\">Sentinel errors for things callers need to branch on<\/h2>\n<p>Sometimes the caller needs to do different things depending on what went wrong. That&rsquo;s where sentinel errors and <code>errors.Is<\/code> come in:<\/p>\n<pre><code class=\"language-go\">var (\n    ErrNotFound     = errors.New(&quot;not found&quot;)\n    ErrUnauthorized = errors.New(&quot;unauthorized&quot;)\n    ErrRateLimited  = errors.New(&quot;rate limited&quot;)\n)\n\nfunc GetUser(id string) (*User, error) {\n    row := db.QueryRow(&quot;SELECT ... WHERE id = $1&quot;, id)\n    var u User\n    if err := row.Scan(&amp;u.ID, &amp;u.Name, &amp;u.Email); err != nil {\n        if errors.Is(err, sql.ErrNoRows) {\n            return nil, fmt.Errorf(&quot;get user %s: %w&quot;, id, ErrNotFound)\n        }\n        return nil, fmt.Errorf(&quot;get user %s: %w&quot;, id, err)\n    }\n    return &amp;u, nil\n}\n<\/code><\/pre>\n<p>Now the HTTP handler can check <code>errors.Is(err, ErrNotFound)<\/code> and return a 404 without knowing anything about SQL. The database is an implementation detail that doesn&rsquo;t leak through the API boundary.<\/p>\n<p>I define sentinels at the package level, one file called <code>errors.go<\/code>. Three to five per package is normal. If you have fifteen, your package is probably doing too much.<\/p>\n<h2 id=\"custom-error-types-when-you-need-structured-data\">Custom error types when you need structured data<\/h2>\n<p>Sentinels work for simple branching. When the caller needs details about what went wrong, use a custom error type:<\/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\nfunc ParseAge(input string) (int, error) {\n    age, err := strconv.Atoi(input)\n    if err != nil {\n        return 0, &amp;ValidationError{\n            Field:   &quot;age&quot;,\n            Message: fmt.Sprintf(&quot;%q is not a number&quot;, input),\n        }\n    }\n    if age &lt; 0 || age &gt; 150 {\n        return 0, &amp;ValidationError{\n            Field:   &quot;age&quot;,\n            Message: fmt.Sprintf(&quot;%d is out of range&quot;, age),\n        }\n    }\n    return age, nil\n}\n<\/code><\/pre>\n<p>The handler uses <code>errors.As<\/code> to extract the details:<\/p>\n<pre><code class=\"language-go\">var ve *ValidationError\nif errors.As(err, &amp;ve) {\n    \/\/ return 400 with ve.Field and ve.Message\n}\n<\/code><\/pre>\n<p>I use this pattern a lot in API services. The transport layer (HTTP handler, gRPC interceptor) checks for <code>*ValidationError<\/code> and maps it to the right status code. Business logic never imports <code>net\/http<\/code>. Clean separation.<\/p>\n<h2 id=\"the-middleware-pattern-for-http-error-mapping\">The middleware pattern for HTTP error mapping<\/h2>\n<p>Instead of checking error types in every handler, I use a single middleware that converts Go errors into HTTP responses:<\/p>\n<pre><code class=\"language-go\">func ErrorMiddleware(next http.Handler) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        next.ServeHTTP(w, r)\n    })\n}\n\n\/\/ In practice, I use a custom handler type:\ntype AppHandler func(w http.ResponseWriter, r *http.Request) error\n\nfunc Wrap(h AppHandler) http.HandlerFunc {\n    return func(w http.ResponseWriter, r *http.Request) {\n        err := h(w, r)\n        if err == nil {\n            return\n        }\n        var ve *ValidationError\n        switch {\n        case errors.Is(err, ErrNotFound):\n            http.Error(w, &quot;not found&quot;, 404)\n        case errors.Is(err, ErrUnauthorized):\n            http.Error(w, &quot;unauthorized&quot;, 401)\n        case errors.As(err, &amp;ve):\n            w.WriteHeader(400)\n            json.NewEncoder(w).Encode(map[string]string{\n                &quot;field&quot;: ve.Field, &quot;error&quot;: ve.Message,\n            })\n        default:\n            log.Printf(&quot;internal error: %v&quot;, err)\n            http.Error(w, &quot;internal error&quot;, 500)\n        }\n    }\n}\n<\/code><\/pre>\n<p>Now handlers just return errors and the middleware does the mapping. This cut about 40% of the error handling boilerplate from my HTTP layer. Handlers went from 30-line functions with five error-response blocks to 10-line functions that return early on error.<\/p>\n<h2 id=\"dont-wrap-errors-you-can-handle-locally\">Don&rsquo;t wrap errors you can handle locally<\/h2>\n<p>One mistake I made early on: wrapping everything. If you can handle the error right where it happens, handle it. Don&rsquo;t wrap it and pass it up just to look thorough:<\/p>\n<pre><code class=\"language-go\">\/\/ Overengineered\nfunc ReadWithDefault(path, fallback string) (string, error) {\n    data, err := os.ReadFile(path)\n    if err != nil {\n        if errors.Is(err, fs.ErrNotExist) {\n            return fallback, nil  \/\/ this is fine\n        }\n        return &quot;&quot;, fmt.Errorf(&quot;read with default: %w&quot;, err)\n    }\n    return string(data), nil\n}\n<\/code><\/pre>\n<p>The <code>ErrNotExist<\/code> case is handled locally. No need to wrap it and send it up. The caller asked for a default and got one. Only wrap and return errors that the current function can&rsquo;t resolve.<\/p>\n<p>This sounds obvious but it took me a while to internalize. The instinct in Go is to always propagate. Sometimes the right call is to log it, recover, and move on.<\/p>\n<h2 id=\"what-i-actually-do-in-every-new-project\">What I actually do in every new project<\/h2>\n<p>Here&rsquo;s my checklist when starting a Go service now. It takes about 20 minutes and saves hours of confusion later:<\/p>\n<ol>\n<li>Create an <code>errors.go<\/code> in each package with 3-5 sentinel errors for that package&rsquo;s failure modes.<\/li>\n<li>Every exported function wraps errors with <code>fmt.Errorf(\"funcname: context: %w\", err)<\/code>.<\/li>\n<li>Define <code>*ValidationError<\/code> and <code>*NotFoundError<\/code> (or similar) as custom types in the domain package.<\/li>\n<li>Write one <code>Wrap<\/code> function for the HTTP\/gRPC layer that maps error types to status codes.<\/li>\n<li>Use <code>errors.Is<\/code> and <code>errors.As<\/code> at API boundaries. Never use string matching.<\/li>\n<\/ol>\n<p>This gives me structured, chainable, inspectable errors from day one. When something breaks at 2am, the log line tells me exactly which function, which step, and what the underlying cause was.<\/p>\n<p>I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/open-source-llms-2026\" rel=\"noopener\">approaching tool evaluation with a practical lens<\/a> before on this blog. Go&rsquo;s error handling is a good example of a design that looks worse on paper than it works in practice. The verbosity is real but the tradeoff is that errors are values, not exceptions. You can test them, wrap them, inspect them, and route them. Once you have decent patterns in place, the <code>if err != nil<\/code> stops being noise and starts being the most readable part of your code.<\/p>\n<p>More of my projects and writing at <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">abrarqasim.com<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Go error handling doesn&#8217;t have to be painful. Practical patterns for error wrapping, sentinel errors, and custom types that cut the if-err-nil noise.<\/p>\n","protected":false},"author":2,"featured_media":73,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Go error handling doesn't have to be painful. Practical patterns for error wrapping, sentinel errors, and custom types that cut the if-err-nil noise.","rank_math_focus_keyword":"go error handling","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[45],"tags":[49,51,48,46,47,50],"class_list":["post-75","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-programming","tag-backend","tag-best-practices","tag-error-handling","tag-go","tag-golang","tag-programming"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/75","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=75"}],"version-history":[{"count":1,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/75\/revisions"}],"predecessor-version":[{"id":80,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/75\/revisions\/80"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/73"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=75"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=75"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=75"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}