Go Error Handling That Doesn’t Suck: %w, errors.Is, and errors.As Patterns

Go’s error handling gets a bad reputation. People call it verbose, primitive, and tedious. Half of those people are copy-pasting if err != nil { return err } without thinking, throwing away all the context that would have saved them an hour of debugging at 2am. The other half are discovering errors.Is and errors.As for the first time and wondering why nobody told them about this three years ago.

This article is for both groups. We’ll cover how the wrapping model actually works, how to use it without shooting yourself in the foot, and what patterns genuinely matter in production code.

What’s Wrong With Bare Error Returns

Before we get to the solution, let’s be honest about the problem.

func readConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err  // raw return — context is gone
    }
    // ...
}

When this blows up, you get something like:

open /etc/myapp/config.yaml: no such file or directory

That message tells you what happened but not where in your call stack, or why this code was trying to read that file in the first place. In a real application with 10 layers of function calls, you’re left guessing.

The old-school fix was string wrapping:

return nil, fmt.Errorf("readConfig: %v", err)

Better message, but now you’ve created a brand new error. The original *os.PathError is gone. You can’t do errors.Is(err, os.ErrNotExist) upstream. You’ve traded structured information for a string.

Enter %w: Wrapping That Preserves Structure

Go 1.13 introduced the %w verb in fmt.Errorf. It does what %v does visually — formats the error as a string — but it also stores a reference to the original error inside the returned error.

func readConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("readConfig %q: %w", path, err)
    }

    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("readConfig %q: parse failed: %w", path, err)
    }

    return &cfg, nil
}

Now the error message reads like a breadcrumb trail, and the original error is still accessible programmatically. The wrapped error chain looks like this:

readConfig "/etc/myapp/config.yaml": parse failed: yaml: line 12: did not find expected key
                                       ^                    ^
                              your context            original error

The %w verb internally calls fmt.Errorf to produce a type that implements the Unwrap() error interface. That’s the hook everything else hangs on.

errors.Is: Checking Identity Through the Chain

errors.Is(err, target) walks the entire unwrap chain and returns true if any error in the chain matches target.

cfg, err := readConfig("/etc/myapp/config.yaml")
if err != nil {
    if errors.Is(err, os.ErrNotExist) {
        // file doesn't exist — maybe use defaults
        return defaultConfig(), nil
    }
    return nil, err
}

This works even though err is a wrapped fmt.Errorf error wrapping an *os.PathError wrapping os.ErrNotExist. errors.Is keeps calling Unwrap() until it finds a match or runs out of chain.

The matching logic: by default, errors.Is uses == comparison. For sentinel errors like io.EOF, sql.ErrNoRows, os.ErrNotExist — simple value comparison works. But types can override this by implementing an Is(target error) bool method.

Sentinel Errors in Your Own Packages

Define them as package-level var, not const (errors aren’t constants in Go):

var (
    ErrNotFound   = errors.New("not found")
    ErrPermission = errors.New("permission denied")
    ErrTimeout    = errors.New("operation timed out")
)

Then wrap them when returning:

func (s *Store) GetUser(id int64) (*User, error) {
    u, ok := s.cache[id]
    if !ok {
        return nil, fmt.Errorf("GetUser %d: %w", id, ErrNotFound)
    }
    return u, nil
}

Upstream code can check cleanly:

user, err := store.GetUser(42)
if errors.Is(err, store.ErrNotFound) {
    http.Error(w, "user not found", http.StatusNotFound)
    return
}

No string parsing. No fragile strings.Contains(err.Error(), "not found"). That pattern is the error-handling equivalent of parsing HTML with regex — it works until it doesn’t, and when it breaks you’ll have no idea why.

errors.As: Extracting Typed Errors

errors.Is is great for sentinel values, but sometimes you need the actual error struct — you want the HTTP status code from an *APIError, the line number from a *ParseError, or the query that caused a *DBError.

errors.As(err, &target) walks the chain and sets target to the first error that can be assigned to target’s type. Returns true if found.

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}

// Unwrap lets errors.As and errors.Is see through this type
func (e *QueryError) Unwrap() error {
    return e.Err
}

Now upstream:

result, err := db.Execute(query)
if err != nil {
    var qErr *QueryError
    if errors.As(err, &qErr) {
        log.Printf("failed query: %s", qErr.Query)
        metrics.QueryErrors.Inc()
    }
    return err
}

Note the &qErr — you pass a pointer to a pointer. This is a common source of confusion. errors.As needs to be able to set your variable, so it needs its address.

errors.As With Interface Targets

errors.As also works with interface types:

type Retryable interface {
    IsRetryable() bool
}

var retryable Retryable
if errors.As(err, &retryable) && retryable.IsRetryable() {
    // retry the operation
}

This is a clean way to add behavior to errors without coupling caller and callee on a concrete type.

Building a Proper Custom Error Type

Here’s a complete example putting everything together — the kind of error type that actually belongs in production code:

// AppError is a structured application error with an HTTP status hint.
type AppError struct {
    Code    int    // HTTP status code suggestion
    Message string // user-facing message (safe to expose)
    Err     error  // underlying cause (internal only)
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

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

// Helper constructors keep call sites clean.
func NotFound(msg string, cause error) *AppError {
    return &AppError{Code: 404, Message: msg, Err: cause}
}

func Unauthorized(msg string) *AppError {
    return &AppError{Code: 401, Message: msg}
}

In a handler:

func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    article, err := h.store.FindArticle(id)
    if err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            http.Error(w, appErr.Message, appErr.Code)
            return
        }
        // unknown error — don't leak internals
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(article)
}

The HTTP layer doesn’t know anything about database errors, YAML parse errors, or network failures. It just checks if the error implements the shape it cares about.

Multiple Wrapping: Go 1.20 and errors.Join

Go 1.20 added errors.Join, which wraps multiple errors into one. errors.Is and errors.As check all of them.

func validateUser(u *User) error {
    var errs []error

    if u.Name == "" {
        errs = append(errs, ErrEmptyName)
    }
    if u.Email == "" {
        errs = append(errs, ErrEmptyEmail)
    }
    if len(u.Password) < 8 {
        errs = append(errs, ErrWeakPassword)
    }

    return errors.Join(errs...)
}
err := validateUser(u)
if errors.Is(err, ErrEmptyEmail) {
    // true, even though there might be other errors too
}

Also, Go 1.20 extended fmt.Errorf to support multiple %w verbs in a single call:

err := fmt.Errorf("operation failed: %w, also caused by: %w", err1, err2)

Both err1 and err2 are reachable via errors.Is and errors.As. Use this sparingly — errors.Join is cleaner for the validation pattern above.

Gotchas

Gotcha 1: %v vs %w — an easy typo with big consequences.

// Wrong — creates a new error, original is gone
return fmt.Errorf("something went wrong: %v", err)

// Right — wraps err, original is preserved
return fmt.Errorf("something went wrong: %w", err)

Your IDE won’t catch this. The code compiles and runs. You just silently lose the ability to use errors.Is upstream. Write a lint rule or use errorlint (it catches exactly this).

Gotcha 2: Returning interface-typed nil.

This one bites everyone eventually:

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func doSomething() error {
    var err *MyError = nil
    return err  // NOT nil — this is a non-nil interface wrapping a nil pointer
}
err := doSomething()
if err != nil {
    // This branch is taken! err is a non-nil interface value.
}

Return nil directly, not a typed nil pointer. If you’re returning from a variable, return nil explicitly when there’s no error.

Gotcha 3: errors.As requires a pointer receiver when matching pointer types.

var target MyError   // value — won't match *MyError
errors.As(err, &target) // false

var target *MyError  // pointer — matches *MyError in the chain
errors.As(err, &target) // true

If your error type is a pointer receiver on all methods (the common case), always use a pointer target in errors.As.

Gotcha 4: Don’t wrap errors you’re about to log and swallow.

if err != nil {
    log.Printf("failed: %v", err)  // logged
    return nil                      // swallowed — caller never knows
}

Wrapping is meaningless if you swallow the error. Either propagate it up (with wrapping), or log it and return a clean value — never both at the same layer. If you log and return, you’ll end up with duplicate log entries stacked through every layer.

Gotcha 5: errors.New vs fmt.Errorf for sentinel values.

// Wrong — creates new error value on every call, breaks == comparison
func ErrNotFound() error { return errors.New("not found") }

// Right — single value, comparable by identity
var ErrNotFound = errors.New("not found")

errors.Is uses == for basic error comparison. If you return a freshly allocated error each time, == will never match.

The errorlint Tool — Run It

Install it and add it to your CI:

go install github.com/polyfloyd/errorlint@latest
errorlint ./...

It catches: using %v instead of %w, direct == comparisons against error values (use errors.Is), and type assertions on errors (use errors.As). No excuses for skipping it.

Practical Wrapping Convention

Adopt a consistent format across your codebase. I use:

<package or function>: <what you were trying to do>: <wrapped error>
return fmt.Errorf("store.GetUser id=%d: %w", id, err)
return fmt.Errorf("config.Load path=%q: %w", path, err)
return fmt.Errorf("http.Do POST %s: %w", url, err)

Including the relevant parameter in the context message — the ID, path, URL — turns a generic error into an immediately actionable one. You’re not writing a novel; you’re leaving a breadcrumb that makes the 3am debugging session survivable.

What Not to Do: The Third-Party Package Temptation

There are popular packages like github.com/pkg/errors that predate Go 1.13 and do their own wrapping with stack traces. If your codebase already uses pkg/errors, you need to be careful: it doesn’t interop cleanly with errors.Is/errors.As out of the box unless you use errors.Cause (its own API) or the newer errors.As-compatible shim.

For new code: stick with the standard library. fmt.Errorf + %w + errors.Is + errors.As covers 95% of real use cases. Stack traces are tempting but they’re expensive and rarely necessary if you’re wrapping with good context at each layer.

Quick Reference

// Wrap — adds context, preserves original
return fmt.Errorf("op %s: %w", name, err)

// Check sentinel identity (through chain)
errors.Is(err, io.EOF)
errors.Is(err, os.ErrNotExist)
errors.Is(err, mypackage.ErrNotFound)

// Extract typed error (through chain)
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println(pathErr.Path)
}

// Unwrap manually (one step)
cause := errors.Unwrap(err)

// Combine multiple errors (Go 1.20+)
return errors.Join(err1, err2, err3)

// Define a sentinel
var ErrInvalid = errors.New("invalid")

// Custom type with chain support
type MyError struct { Code int; Err error }
func (e *MyError) Error() string { return fmt.Sprintf("[%d] %v", e.Code, e.Err) }
func (e *MyError) Unwrap() error { return e.Err }

The error model in Go is simple by design. The complexity you see in the wild comes from people ignoring %w and errors.As and compensating with string matching, panic/recover abuse, or imported packages that add stack traces without solving the actual problem.

Use %w every time you wrap. Write errors.Is instead of == err. Write errors.As instead of type assertions on errors. Keep your sentinel errors as package-level var. Run errorlint. That’s the whole playbook.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646