Back to blog

Sunday, October 25, 2026

Why Is Go Error Handling So Verbose?

Title card for 'Why Is Go Error Handling So Verbose?' with a short code example

Introduction

Go error handling is verbose because Go makes failure visible. A function returns an error value, the caller checks that value with if err != nil, and the caller decides what to do with it, in the open, at every call site. There is no exception that can travel past a caller without that caller seeing a check. This article explains the pattern, the tools that make it work, the history behind it, and a complete HTTP example that follows the same rules from the data layer to the response.

Before You Start

  • Error: a value that implements the error interface, which has one method, Error() string. A function signals failure by returning a non nil error, and signals success by returning a nil error.
  • Nil error: the zero value for the error interface. A nil error means the operation succeeded. A non nil error means it failed, even if its printed text looks like <nil>, as the typed nil section explains.
  • Sentinel error: a package level error value created once with errors.New, for a fixed condition such as not found. Callers check it with errors.Is.
  • Wrapping: creating a new error that contains an earlier error, plus new context. Wrapping preserves the cause for inspection, while plain text formatting does not.
  • Error chain: the sequence of errors produced by repeated unwrapping, from the outermost wrapper down to the original cause.
  • Panic: a runtime mechanism that stops the current goroutine and unwinds through deferred functions. Panic is for programmer bugs and process killing startup failures, not for expected failures. This article does not use panic for normal errors.

Prerequisites

  • Go 1.20 or later, because the article uses errors.Join and multiple %w verbs in one fmt.Errorf call, both added in Go 1.20.
  • A terminal, and a text editor.
  • No third party packages are required for the complete example. The concurrency section uses golang.org/x/sync/errgroup, which is a separate module, and that section says so where it is used.
go version

Tested with: Go 1.22.2, with go 1.20 in go.mod. Verified on 2026-10-25.

Project Layout

user-lookup/
├── go.mod          # module declaration, Go version
├── main.go         # repository, service, handler, and server
└── main_test.go    # service and handler tests
  • go.mod: declares the module and the minimum Go version.
  • main.go: holds every layer of the complete example in one file, so the layering can be read in order.
  • main_test.go: holds the tests for the service contract and for the HTTP status mapping.

The Basic Pattern

Go functions return errors as values, usually as the last return value. The caller checks the error immediately, and returns early on failure. That early return keeps the success path at the left margin, instead of nesting it inside a success branch.

// main.go
func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println(err)
    } else {
        fmt.Println(result)
    }
}

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

The pattern has three parts, and every Go program repeats them. Call a function. Check its error. Decide: return it, wrap it with context, translate it into a caller facing error, or handle it and continue. The verbosity comes from the fact that this decision is written out, not hidden. A short function with three fallible calls can spend more lines on error checks than on its main logic, because each check takes a few lines. Go accepts that cost in exchange for clarity, especially in a large codebase where a production failure must be traced quickly.

Why Go Keeps The Verbose Pattern

Exceptions, as used in many other languages, move failure handling away from the call site. A function can throw from deep inside a call chain, and the caller may not know which calls can throw without reading their documentation or source. That makes control flow harder to see in a diff, and a reviewer can miss a failure path during review. Go does not use that model for normal errors, while keeping panic for genuine programmer errors. The Go FAQ gives the reason: coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code, and it tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

Rust takes a middle path. A Rust function returns a Result type, and the ? operator returns early from the current function if the result is an error, converting it if needed. That is concise, and the Result type still makes failure visible in the signature. Go has no equivalent operator, and the history below explains why that is unlikely to change.

The first explicit Go team attempt dates to the Go 2 effort in 2018, with a check and handle design. In that design, a handler declared once at the top of a function decided what happened on error, and check invoked it. The Go team later described that design as too complicated, and it never progressed beyond draft stage.

In 2019, the team followed up with a much simpler proposal: a built in try function. Writing x := try(strconv.Atoi(a)) would return from the enclosing function if the call failed, without an explicit if block. The proposal drew close to 900 comments and was abandoned. Griesemer's post on this history gives the reason in its own words: try hid control flow from view, because it could return from deeply nested expressions, and user sentiment was very strongly not in favor.

In 2024, Ian Lance Taylor published a proposal to borrow Rust style ? syntax for Go, in the form x := strconv.Atoi(a) ?. That proposal was closed and moved to a discussion, and a modified version was received a bit more positively, but it still lacked broad support, after a flood of comments and minor tweak suggestions.

In 2025, Robert Griesemer published a post titled "[On | No] syntactic support for error handling" that draws a line under the whole debate. In that post, Griesemer says the Go team will stop pursuing syntactic language changes for error handling for the foreseeable future, and will close open and incoming proposals that concern themselves primarily with the syntax of error handling, without further investigation. The post gives consensus as the reason: none of the proposals reached anything close to a consensus, and a new syntax construct would force almost every Go programmer to learn and use it, unlike generics, which most users can ignore thanks to type inference.

The practical answer to "will this ever get shorter" is therefore: do not wait for new syntax. Instead of new syntax, Go 1.13 added %w, errors.Is, and errors.As, and Go 1.20 later added errors.Join, so the existing if err != nil check could carry more information without becoming a new language feature. The explicit pattern stayed, and the tools around it got stronger. The verbosity remains, but each check now does more work: it can add context, preserve the cause for inspection, and let the boundary decide the response.

Creating Errors

Go provides two standard ways to create a plain error, and one rule that decides between them.

  • errors.New: creates an error whose only content is a text message. Each call returns a distinct value, even if the text is identical.
  • fmt.Errorf: formats a message, like fmt.Sprintf, and returns it as an error. Use it when the message needs values from the call, such as an identifier or a path.
  • Sentinel creation: create a sentinel once, at package level, with errors.New, and return that same value for the same condition. Do not create a new sentinel inside a function on every call, or callers cannot match it reliably with errors.Is after wrapping, because each call would be a different value.
// main.go
var ErrUserNotFound = errors.New("user not found")

func lookup(id string) (string, error) {
    if id == "" {
        return "", errors.New("id is empty")
    }
    return "", fmt.Errorf("lookup id %s: %w", id, ErrUserNotFound)
}

The last line already uses wrapping, which the next section explains. The point here is the choice of constructor: errors.New for a fixed message, fmt.Errorf for a message with values, and a package level variable for a condition callers must branch on.

Wrapping Errors With Context

Wrapping adds context as an error travels up the call stack, without losing the original cause. Go 1.13 added it to the standard library in three pieces: the %w verb of fmt.Errorf, the convention that an error that wraps another has an Unwrap() error method, and the functions errors.Unwrap, errors.Is, and errors.As in the errors package.

  • %w: formats the error argument into the message, and makes the returned error wrap that argument. The returned error has an Unwrap method that returns the argument.
  • %v: formats the error argument into the message only. The link is lost. A caller can read the text, but cannot inspect the cause with errors.Is or errors.As.
  • Wrapping as contract: wrapping an error makes that error part of your public API. A caller can reasonably write code that depends on it. If you do not want to promise that, use %v, or translate the error into your own error type, and do not wrap the internal error.
// main.go
func getUser(id string) (User, error) {
    u, err := storeLookup(id)
    if err != nil {
        return User{}, fmt.Errorf("get user %s: %w", id, err)
    }
    return u, nil
}

Wrap only when you add context. Wrapping the same error at every layer with no new information adds noise to logs and to the printed chain, without helping a reader. Wrap at the layer that knows something new: which identifier was looked up, which file was opened, which step failed.

Wrapping Multiple Errors

Go 1.20 expanded wrapping to multiple errors. An error can wrap more than one error by providing an Unwrap method that returns []error. errors.Is and errors.As were updated to inspect multiply wrapped errors, and fmt.Errorf now supports multiple %w verbs in one call. The new function errors.Join returns an error wrapping a list of errors, discarding nil values, and returning nil if every value is nil. Its text is the joined errors separated by newlines.

// main.go
err := errors.Join(err1, err2)
if err != nil {
    fmt.Println(err)
}

Use errors.Join for independent failures that all happened, such as validation failures, not for a chain where one failure caused the next. A chain uses %w. A list uses errors.Join.

Reading An Error Chain

Reading a chain means asking a question about the cause, not reading the text. Text changes when wording changes. Meaning survives refactoring, if the code uses the right tool.

  • errors.Is: reports whether any error in the chain matches a target value. Use it for a sentinel, or for a fixed condition. It is preferable to == after wrapping, because == only matches the outermost value.
  • errors.As: finds the first error in the chain that can be assigned to a target, assigns it, and returns true. Use it when the caller needs data from the error, such as a field.
  • errors.Unwrap: returns the result of calling an Unwrap method, or nil if there is none. Use it rarely, for manual inspection. errors.Is and errors.As examine the whole chain in one call, so they are the usual choice.
  • Custom Is: an error in the chain may declare that it matches a target by implementing an Is(error) bool method. That method should only shallowly compare the error and the target, and should not call Unwrap on either.
// main.go
if errors.Is(err, fs.ErrNotExist) {
    fmt.Println("file does not exist")
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println(pathErr.Path)
}

The target for errors.As must be a non nil pointer to a variable that can hold the matching error type, or a non nil pointer to any interface type. Passing pathErr instead of &pathErr is a common mistake, and errors.As panics if the target is nil, if it is not a pointer, or if it points to a type that does not implement error and is not an interface type. Check the & every time.

The OS Open Example

os.Open on a missing file returns a *os.PathError wrapping a syscall.Errno, for example ENOENT. It does not wrap fs.ErrNotExist directly. errors.Is(err, fs.ErrNotExist) still works, because syscall.Errno implements its own Is method, which reports a match for fs.ErrNotExist when the errno is ENOENT. This is the custom Is behavior in action: the chain does not contain fs.ErrNotExist itself, but the match still succeeds through the Is method of the error in the chain.

Sentinel Errors

A sentinel error is a fixed condition, declared once, that callers branch on. Examples are not found, invalid input, permission denied, and already exists. The rules are short, and breaking them is the most common source of fragile error code.

  • Declare once: one package level variable per condition, with errors.New.
  • Return, do not recreate: return the sentinel, or an error wrapping it, so errors.Is can find it.
  • Check with Is: use errors.Is, never ==, once wrapping is in play, and never text comparison.
  • Only for conditions callers branch on: if no caller needs to distinguish this condition from other failures, do not make it a sentinel. Return a wrapped error with context, or a custom type, instead.
  • Document the promise: if a function returns an error wrapping a sentinel, say so in its documentation, because that wrapping is now part of its API.
// main.go
var ErrUserNotFound = errors.New("user not found")

func handle(id string) error {
    _, err := getUser(id)
    if errors.Is(err, ErrUserNotFound) {
        return fmt.Errorf("user %s: %w", id, err)
    }
    return err
}

Custom Error Types

A custom error type carries structured data, not only a message. Use it when the caller needs fields, such as a field name, an identifier, or a public code. Define the type, implement Error() string, and implement Unwrap() error if it wraps another error, so errors.Is and errors.As can see inside it.

// main.go
type AppError struct {
    Code    string
    Message string
    Err     error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return e.Err.Error()
    }
    return e.Message
}

func (e *AppError) Unwrap() error {
    return e.Err
}

This type is used at an API boundary in the complete example. It carries a public code and a public message for the client, while still preserving the internal cause for logs. Note what it deliberately does not carry: no HTTP status code. The service layer must not decide HTTP statuses, or it leaks HTTP knowledge into domain code. Instead, the service creates an AppError with a domain code such as user_not_found, and the central handler maps that code to an HTTP status. Error() returns the internal chain, not the public message, so logs show the cause trail, while the client sees only Message and Code.

Reducing Verbosity Without Hiding Errors

Verbosity can be reduced, but only by centralizing a response, never by hiding a check. The central handler pattern does that for HTTP. Handlers return an error instead of writing the response themselves. Services return an AppError for known client errors, and the central handler extracts it with errors.As and maps its code to an HTTP status. The service never chooses the status, so domain code stays free of HTTP knowledge.

// main.go
type appHandler func(http.ResponseWriter, *http.Request) error

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    err := fn(w, r)
    if err == nil {
        return
    }

    status := http.StatusInternalServerError
    message := "internal server error"
    code := "internal_error"

    var appErr *AppError
    if errors.As(err, &appErr) {
        code = appErr.Code
        message = appErr.Message
        switch code {
        case "invalid_user_id":
            status = http.StatusBadRequest
        case "user_not_found":
            status = http.StatusNotFound
        }
    }

    if status >= 500 {
        log.Printf("method=%s path=%s handler error: %v", r.Method, r.URL.Path, err)
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if encErr := json.NewEncoder(w).Encode(map[string]string{"error": message, "code": code}); encErr != nil {
        log.Printf("method=%s path=%s encode error response failed: %v", r.Method, r.URL.Path, encErr)
    }
}

If an AppError code has no case in the switch, the status stays 500, but the message and code are still the AppError's own. The client does not get the generic internal_error response in that case, it gets a 500 carrying a client error code, which is a mapping bug. Map every public code. Known client errors, such as a 400 or a 404, are expected and are not logged as server errors, which keeps production logs from filling with noise. Only server errors, status 500 and above, are logged in full, once, at the boundary, with all their context, and that log line includes method and path, so it can be correlated with the request in production. In a real service, add a request id from middleware or context to that log line as well. The client receives a safe message and code. Internal details are never sent to the client for unknown errors. Each handler stays short, because it only returns the error and lets this one place decide the response. The encode error is also checked, not discarded: after WriteHeader the status is already committed, so an encode failure cannot change the response, and logging it is the only useful action left.

There is one more boundary rule, shown in the complete example: once a handler has started writing a successful response, an error can no longer be turned into a different response. Encode the successful body to a buffer first, so encode errors happen before headers are sent and can still be returned. A write error after headers are sent can only be logged, not returned to the central handler, because returning it would make the central handler call WriteHeader a second time and write a second body.

Design Decisions And Tradeoffs

OptionUse whenCost
Sentinel errorThe caller branches on a fixed condition, such as not foundThe sentinel becomes part of the API, and changing it breaks callers that match it
Custom error typeThe caller needs data, such as a field or a public codeMore code to define and document, and the type must implement Unwrap to stay inspectable
Wrap with %wThe caller should be able to inspect the cause, and the cause is not an implementation detailThe wrapped error becomes part of the API, and must be kept stable
Format with %vThe cause is an implementation detail, or the message is for a person onlyPrograms cannot inspect the cause, so callers cannot branch on it
Join with errors.JoinSeveral independent failures happened at once, such as validation failuresThe result is a list, not a chain, and its text joins the errors with newlines
Central handlerMany handlers share the same response and logging rulesHandlers must return errors instead of writing responses, and the handler must map every public code
PanicA programmer bug, or a process that cannot startAn unrecovered panic kills the process, and panic must not cross a public API as a normal error path

A design in this article has a deliberate compromise, stated openly. The complete example puts the repository, service, and handler in one file, so the layering can be read in order. In a real service, those layers live in separate packages, and the service must not import HTTP status codes at all. The single file here is a teaching compromise, not a production layout. The layering rule itself is not compromised: the service still returns only a domain code, and only the central handler maps that code to a status.

Complete Example

The complete example is a user lookup service. A repository layer returns sentinel errors. A service layer translates known errors into an AppError and wraps unknown errors with context. A central handler extracts the AppError with errors.As and turns it into an HTTP response. No layer logs, except the central handler for server errors, and no layer repeats response logic.

// go.mod
module userlookup

go 1.20
// main.go
package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "net/http"
)

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

type AppError struct {
    Code    string
    Message string
    Err     error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return e.Err.Error()
    }
    return e.Message
}

func (e *AppError) Unwrap() error {
    return e.Err
}

var (
    ErrUserNotFound = errors.New("user not found")
    ErrInvalidID    = errors.New("invalid user id")
)

var users = map[string]User{
    "1": {ID: "1", Name: "Asha"},
}

func getUser(id string) (User, error) {
    if id == "boom" {
        return User{}, errors.New("database unavailable")
    }
    u, ok := users[id]
    if !ok {
        return User{}, ErrUserNotFound
    }
    return u, nil
}

func getUserService(id string) (User, error) {
    if id == "" {
        return User{}, &AppError{
            Code:    "invalid_user_id",
            Message: "invalid user id",
            Err:     ErrInvalidID,
        }
    }

    u, err := getUser(id)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            return User{}, &AppError{
                Code:    "user_not_found",
                Message: "user not found",
                Err:     fmt.Errorf("lookup id %s: %w", id, err),
            }
        }
        return User{}, fmt.Errorf("get user %s: %w", id, err)
    }
    return u, nil
}

type appHandler func(http.ResponseWriter, *http.Request) error

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    err := fn(w, r)
    if err == nil {
        return
    }

    status := http.StatusInternalServerError
    message := "internal server error"
    code := "internal_error"

    var appErr *AppError
    if errors.As(err, &appErr) {
        code = appErr.Code
        message = appErr.Message
        switch code {
        case "invalid_user_id":
            status = http.StatusBadRequest
        case "user_not_found":
            status = http.StatusNotFound
        }
    }

    if status >= 500 {
        log.Printf("method=%s path=%s handler error: %v", r.Method, r.URL.Path, err)
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if encErr := json.NewEncoder(w).Encode(map[string]string{"error": message, "code": code}); encErr != nil {
        log.Printf("method=%s path=%s encode error response failed: %v", r.Method, r.URL.Path, encErr)
    }
}

func userHandler(w http.ResponseWriter, r *http.Request) error {
    id := r.URL.Query().Get("id")
    u, err := getUserService(id)
    if err != nil {
        return err
    }

    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(u); err != nil {
        return err
    }

    w.Header().Set("Content-Type", "application/json")
    if _, err := w.Write(buf.Bytes()); err != nil {
        log.Printf("method=%s path=%s write response failed: %v", r.Method, r.URL.Path, err)
    }
    return nil
}

func main() {
    http.Handle("/user", appHandler(userHandler))
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Commands to run it, from the user-lookup folder:

go run .

Expected responses, with the server running on port 8080:

curl "http://localhost:8080/user?id=1"
curl "http://localhost:8080/user?id=99"
curl "http://localhost:8080/user"
curl "http://localhost:8080/user?id=boom"

Request behavior:

  • A request with id 1 returns status 200 and the user as JSON.
  • A request with an unknown id returns status 404, code user_not_found, and a safe message for user not found. The service wraps the lookup with the actual id, so the internal chain names the id that was looked up.
  • A request with no id returns status 400, code invalid_user_id, and a safe message for invalid user id. This is an expected client error, so it is not logged as a server error.
  • A request with id boom hits the simulated internal failure in getUser, returns status 500, code internal_error, and a generic message to the client, while the log contains the full chain for debugging. The 500 path is therefore reachable in the demo, not only described.

The service layer does not know HTTP status codes at all. It returns an AppError with a domain code, and only the central handler maps that code to a status. The handler does not know about the data store. The central handler is the only place that maps errors to HTTP responses, and it logs only server errors, not expected client errors. Note also the buffer in userHandler: the user is encoded to a buffer before any header is sent, so an encode error can still be returned safely. A write error after headers are sent cannot be returned, because the central handler would then try to write a second response, so it is logged in place instead.

Testing Errors

Test errors by meaning, not by text. For a wrapped sentinel, use errors.Is. For a custom type, use errors.As and then check its fields.

// main_test.go
package main

import (
    "errors"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestGetUserServiceSentinel(t *testing.T) {
    tests := []struct {
        name    string
        id      string
        wantErr error
    }{
        {name: "empty id", id: "", wantErr: ErrInvalidID},
        {name: "unknown id", id: "99", wantErr: ErrUserNotFound},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := getUserService(tt.id)
            if !errors.Is(err, tt.wantErr) {
                t.Errorf("getUserService(%q) error = %v, want %v", tt.id, err, tt.wantErr)
            }
        })
    }
}

func TestGetUserServiceAppError(t *testing.T) {
    _, err := getUserService("99")
    var appErr *AppError
    if !errors.As(err, &appErr) {
        t.Fatalf("getUserService(99) error = %v, want an AppError", err)
    }
    if appErr.Code != "user_not_found" {
        t.Errorf("code = %q, want user_not_found", appErr.Code)
    }
    if !errors.Is(err, ErrUserNotFound) {
        t.Errorf("error = %v, want it to wrap ErrUserNotFound", err)
    }

    _, err = getUserService("boom")
    if err == nil {
        t.Fatal("getUserService(boom) error = nil, want an error")
    }
    if errors.As(err, &appErr) {
        t.Errorf("getUserService(boom) error = %v, want no AppError", err)
    }
}

func TestUserHandlerStatus(t *testing.T) {
    tests := []struct {
        id         string
        wantStatus int
        wantCode   string
    }{
        {id: "", wantStatus: 400, wantCode: "invalid_user_id"},
        {id: "99", wantStatus: 404, wantCode: "user_not_found"},
        {id: "boom", wantStatus: 500, wantCode: "internal_error"},
        {id: "1", wantStatus: 200, wantCode: ""},
    }

    for _, tt := range tests {
        req := httptest.NewRequest("GET", "/user?id="+tt.id, nil)
        rec := httptest.NewRecorder()
        appHandler(userHandler).ServeHTTP(rec, req)
        if rec.Code != tt.wantStatus {
            t.Errorf("id %q status = %d, want %d", tt.id, rec.Code, tt.wantStatus)
        }
        if tt.wantCode != "" && !strings.Contains(rec.Body.String(), tt.wantCode) {
            t.Errorf("id %q body = %s, want it to contain %q", tt.id, rec.Body.String(), tt.wantCode)
        }
    }
}

The handler test needs net/http/httptest and strings in the test file imports, in addition to errors and testing. It checks the full contract: service code, handler status, and client facing code, for client errors, for the internal boom error, and for success. If the service changes its code but not its status mapping, or vice versa, one of these tests fails, instead of both passing while the boundary drifts.

For validation with several errors, use errors.Join in the code under test, and check each expected error with errors.Is in the test. Do not check the joined message as one string.

Commands to run the tests and the static checks:

go vet ./...
go test ./...
errcheck -blank ./...

Expected output for the tests is a pass for every subtest, including the boom case, because the handler test asserts the 500 mapping directly. go vet reports suspicious constructs. errcheck reports functions whose returned error is ignored. By default, errcheck does not flag _ = assignments. To catch those as well, run it with the -blank flag. Even then, do not assume every Close is checked: linters such as golangci-lint exclude some Close calls by default in their errcheck configuration, so check your linter configuration for Close, Flush, and Encode cases that look harmless in isolation. Add errorlint as well: it catches direct == comparisons on errors, which should be errors.Is. Its fmt.Errorf check for %v where %w was intended is off by default, so enable it with the -errorf flag, and only then does it flag that case. Put these checks in continuous integration, so a new unchecked error fails the build instead of reaching production as a silent failure.

Common Mistakes

  • Comparing errors with == after wrapping: a wrapped error is a different value from the sentinel it wraps, so == fails even when the cause matches. The fix is errors.Is, which walks the chain.
  • Comparing error text: text changes when wording changes, even when behavior is correct. The fix is errors.Is for a sentinel, and errors.As for a type, as the testing section shows.
  • Using %v when %w was intended: %v formats the cause into text and loses the link, so errors.Is and errors.As cannot find it later. The fix is %w when the caller should be able to inspect the cause, and %v only when the cause must stay hidden.
  • Wrapping without adding context: wrapping the same error at every layer adds noise to logs and to the printed chain. The fix is to wrap only at the layer that knows something new.
  • Wrapping an implementation detail: wrapping a database error in a public function makes that error part of the API, and changing the database later breaks callers. The fix is to translate the error into your own sentinel or type, or to use %v for the internal error, and to document the promise if you do wrap it.
  • Typed nil interface: a function that returns a nil concrete pointer as an error returns a non nil interface, because the interface holds a type and a nil value. The caller sees err != nil as true, and prints <nil>. The fix is to return a nil error directly on success, never a nil concrete pointer boxed as an interface.
// main.go
type DBError struct {
    Query string
}

func (e *DBError) Error() string {
    return "database error: " + e.Query
}

func load() error {
    var dbErr *DBError
    return dbErr
}

load returns a non nil error interface, because the interface holds a type, *DBError, and a nil value. The caller sees err != nil as true, even though the printed error is <nil>. The fix is to return a nil error directly on success, never a nil concrete pointer boxed as an interface: check the pointer first, and return nil when it is nil.

  • Ignoring Close and Flush errors on writes: a write can fail during close or flush, after the main write succeeded, and the data can be lost silently. The fix is to check the close error, and to combine it with any earlier error using errors.Join, so neither failure is lost.
// main.go
func writeFile(path string, data []byte) error {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    _, writeErr := f.Write(data)
    closeErr := f.Close()
    return errors.Join(writeErr, closeErr)
}
  • Discarding an error with _ without a reason: _ = json.NewEncoder(w).Encode(u) hides a failure that the caller could have acted on. The fix is to check the error, or to add a comment stating why it cannot be acted on, for example because the response headers are already committed and logging is the only useful action left.
  • Returning an error after the response started: encoding a user directly to the response writer, then returning an encode error, makes the central handler call WriteHeader a second time and write a second body. The fix is the buffer pattern in the complete example: encode to a buffer first, return encode errors before headers are sent, and log write errors after headers are sent instead of returning them.
  • Errors in goroutines, with no real concurrency: a goroutine cannot return an error to its caller the way a normal function can. Starting one goroutine and immediately blocking on its channel is equivalent to calling the function directly, with added channel complexity and no concurrency benefit. Use several workers, as below, or go straight to errgroup.
// main.go
func processAll(ids []string) error {
    var wg sync.WaitGroup
    errc := make(chan error, len(ids))

    for _, id := range ids {
        wg.Add(1)
        go func(id string) {
            defer wg.Done()
            errc <- process(id)
        }(id)
    }

    wg.Wait()
    close(errc)

    var errs []error
    for err := range errc {
        if err != nil {
            errs = append(errs, err)
        }
    }
    return errors.Join(errs...)
}

The channel is buffered with the number of workers, so no worker blocks sending its result. The caller waits for all workers, then returns every failure at once, instead of only the first one. If only the first error is needed and other workers should stop early, use errgroup from golang.org/x/sync/errgroup, with context:

// main.go
g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
    id := id
    g.Go(func() error {
        return processWithContext(ctx, id)
    })
}
return g.Wait()

The id := id line matters. With go 1.20 in go.mod, the loop variable is shared across iterations, so a closure that captures id directly sees the last value for every worker. The extra assignment gives each iteration its own copy. Without it, every worker in this example would process the last id. Here, g.Wait() returns the first error, and the context is cancelled when that error happens, so workers that respect the context stop early. Cancellation of the shared context happens only when the group is created with errgroup.WithContext. A plain errgroup.Group with no context does not cancel anything, so do not assume cancellation unless you created the group that way.

  • Bad errors.As target: passing a value instead of a pointer, passing a nil target, or passing a pointer to a type that does not implement error and is not an interface type makes errors.As panic. The fix is to pass &target, where target is a variable of the matching error type or interface type, and to check the & every time.
  • Logging at every layer: logging the same failure in the repository, the service, and the handler produces three log lines for one failure, and makes counting failures unreliable. The fix is to log once, at the boundary, for server errors, and to record expected client errors as metrics instead of server error logs, if counts are needed.
  • A validation check in the wrong layer: an empty id check inside getUser cannot be reached when the service already rejects an empty id, so it confuses readers and hides the real contract. The fix is to validate in one layer only: keep the empty id check in the service, and keep getUser focused on lookup and on the simulated internal failure.

Production Notes

  • Security: never send internal details to the client for unknown errors. The central handler returns a generic message and code for a 500, and the full chain goes to logs only. Do not include database errors, file paths from internal storage, or stack traces in a client response.
  • Logging and observability: log server errors once, at the boundary, with method, path, and a request id from middleware or context, so a log line can be correlated with a request. Record expected client errors as metrics, not as server error logs, so alerts fire on server failures, not on user mistakes.
  • Configuration: the complete example listens on port 8080 and uses an in memory map. In production, replace the map with a real store, read the port and any credentials from configuration, and never hardcode credentials or personal data in code. Use obvious placeholders in examples, and explain where real values go.
  • Resource limits: check Close and Flush errors on writes, as the common mistakes section shows, and do not leak goroutines. A goroutine that sends on an unbuffered channel with no receiver blocks forever. Buffer the channel, or use errgroup, and always wait for workers before returning.
  • Deployment: the example needs Go 1.20 or later. Pin that version in go.mod, run go vet ./..., go test ./..., and errcheck -blank ./... in continuous integration, and do not deploy a change that fails those checks.
  • What changes at scale: at scale, the central handler pattern becomes more valuable, not less, because one place controls the client facing codes, the logging rule, and the safe message policy. The cost is that every new public code must be added to the handler mapping and to the handler test, or a known client error returns status 500 while still carrying its own code and message.

Decision Guide

  • When the caller needs to branch on a fixed condition, such as not found or invalid input: declare a sentinel error once, return it, and check it with errors.Is.
  • When the caller needs data from the error, such as a field name or a public code: define a custom error type, implement Error() string, implement Unwrap() error if it wraps another error, and extract it with errors.As. Let the boundary map that code to an HTTP status, instead of putting the status in the domain error itself.
  • When a lower layer failed and the upper layer knows useful context: wrap with fmt.Errorf and %w, adding only the new context.
  • When several independent errors happened at once, such as validation failures: combine them with errors.Join.
  • When the error reached a boundary, such as an HTTP handler or a CLI main function: for a server error, log the full chain once, translate it into a safe response for the user, and do not send internal details to the client. For an expected client error, such as a 400 or a 404, do not log it as a server error; return the safe response and, if counts are needed, record it as a metric instead.
  • When the failure is a programmer bug or the program cannot continue at all: use panic only in that narrow case, and recover only at a top level boundary.

Conclusion

Go error handling is verbose because it is explicit. Every failure is a value that must be checked, and every check forces a decision at the point where the failure happened.

Summary Of Key Points

  • Check immediately: check errors immediately and return early, so the success path stays readable.
  • Wrap with context only: wrap with context only where you add information, with %w when the cause should stay inspectable, and with %v when it must stay hidden.
  • Sentinels for conditions: use sentinel errors for fixed conditions and custom types for structured data.
  • Inspect with Is and As: inspect chains with errors.Is and errors.As, never by comparing text or by == after wrapping.
  • Join independent failures: combine multiple independent errors with errors.Join.
  • Log once at the boundary: log server errors once at the boundary, do not log expected client errors as server errors, and centralize the response in web handlers.

Follow those rules, and the same if err != nil pattern that feels repetitive in a small program becomes a precise trail in a large program. Each layer adds what it knows, the boundary decides what the user sees, and a production failure can be traced from the final log line back to its cause without guessing.

Additional Resources

References

  1. Working with Errors in Go 1.13. Damien Neil and Jonathan Amsterdam, Go Blog. https://go.dev/blog/go1.13-errors. Accessed 2026-10-25.
  2. Errors are values. Rob Pike, Go Blog. https://go.dev/blog/errors-are-values. Accessed 2026-10-25.
  3. [On | No] syntactic support for error handling. Robert Griesemer, Go Blog. https://go.dev/blog/error-syntax. Accessed 2026-10-25.
  4. Go 1.20 Release Notes. Go team, Go Documentation. https://go.dev/doc/go1.20. Accessed 2026-10-25.
  5. errors package. Go team, Go Packages. https://pkg.go.dev/errors. Accessed 2026-10-25.
  6. fmt package. Go team, Go Packages. https://pkg.go.dev/fmt. Accessed 2026-10-25.
  7. io/fs package. Go team, Go Packages. https://pkg.go.dev/io/fs. Accessed 2026-10-25.
  8. errcheck. Kisielk, Go Packages. https://pkg.go.dev/github.com/kisielk/errcheck. Accessed 2026-10-25.
  9. errgroup package. Go team, Go Packages. https://pkg.go.dev/golang.org/x/sync/errgroup. Accessed 2026-10-25.
  10. go-errorlint. Polyfloyd, Go Packages. https://pkg.go.dev/codeberg.org/polyfloyd/go-errorlint. Accessed 2026-10-25.
  11. Frequently Asked Questions. Go team, Go Documentation. https://go.dev/doc/faq. Accessed 2026-10-25.
  12. Recoverable Errors with Result. The Rust Programming Language book. https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html. Accessed 2026-10-25.