CHAPTER 23 · TYPES & ABSTRACTIONS

Error Handling

Go has no exceptions. Instead, functions return error values that callers check explicitly. This reads verbose at first, but pays off in code that's easy to reason about, control flow is linear, and error handling is impossible to "forget".

Learning objectives

  • Understand the error interface.
  • Return, check, and propagate errors.
  • Wrap errors with fmt.Errorf("...: %w", err).
  • Inspect wrapped errors with errors.Is and errors.As.
  • Define custom error types and sentinel values.
  • Know when to use (and especially when not to use) panic.

Errors are values

error is a built-in interface:

type error interface {
    Error() string
}

Any type that implements Error() string satisfies error. The simplest case: errors.New("something went wrong").

Returning errors

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

result, err := divide(10, 0)
if err != nil {
    fmt.Println("error:", err)
    return
}
fmt.Println(result)

The convention: error is the last return value; it's nil on success. Never return a non-nil result alongside a non-nil error (and vice versa), callers shouldn't have to check both.

Wrapping with %w

fmt.Errorf with the %w verb wraps an error, preserving it so callers upstream can inspect it:

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

Now an error message like loadConfig "a.yaml": open a.yaml: no such file or directory tells you both what high-level action failed and the underlying cause, and the original error object is still accessible.

errors.Is and errors.As

if errors.Is(err, os.ErrNotExist) {
    // err chain contains os.ErrNotExist, create the file
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    // err chain contains a *os.PathError: pathErr is now usable
    fmt.Println(pathErr.Path)
}
  • errors.Is(err, target): "does err's chain contain this exact error value?"
  • errors.As(err, &v): "does err's chain contain an error of this type? If so, put it in v."

Both walk the chain created by %w. Never compare with == directly, if the error's been wrapped, == will miss it.

Sentinel errors

A package-level error variable used as a comparable "tag":

var ErrNotFound = errors.New("not found")

func find(id int) (*Item, error) {
    // ...
    return nil, ErrNotFound
}

if errors.Is(err, ErrNotFound) { ... }

Sentinels are great when there's a clear set of known failure modes callers might want to branch on (io.EOF is the classic). Use them sparingly, too many sentinels and your package becomes a jungle.

Custom error types

When the error needs structured data (a code, field, cause):

type ValidationError struct {
    Field   string
    Reason  string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Reason)
}

// usage
var verr *ValidationError
if errors.As(err, &verr) {
    fmt.Println("bad field:", verr.Field)
}

panic & recover

panic aborts the goroutine and unwinds the stack, running deferred functions on the way up. recover (used in a deferred function) stops the unwinding and returns the panic value.

func safeDiv(a, b int) (r int, err error) {
    defer func() {
        if p := recover(); p != nil {
            err = fmt.Errorf("recovered: %v", p)
        }
    }()
    return a / b, nil    // panics if b == 0
}
!
Panic is not error handling Reserve panic for programmer errors (impossible state, nil map writes, out-of-range index) and for truly unrecoverable conditions. For anything a caller might reasonably want to handle, return an error.

Idioms & anti-patterns

Idioms

  • Check errors immediately. Don't stockpile for later.
  • Add context when wrapping. "opening config: ..." is more useful than just "file not found".
  • Don't log-and-return. Either log or return, not both. The caller logs at the top.
  • Use fmt.Errorf for dynamic errors, errors.New for static ones (like sentinels).

Anti-patterns

  • _ = err: silently ignoring. Almost always a bug.
  • Comparing with err.Error() == "no rows": fragile. Use errors.Is.
  • Returning nil from an error-returning function by accident. Always check before returning data.

Check your understanding

Practice exercises

EXERCISE 1

Wrap & inspect

Write a function readConfig(path string) (string, error) that reads a file and, on failure, wraps the underlying error with context (e.g. "readConfig: ...: %w"). Call it with a missing path and use errors.Is(err, os.ErrNotExist) to detect a missing file.

Show solution
package main

import (
    "errors"
    "fmt"
    "os"
)

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

func main() {
    _, err := readConfig("missing.yaml")
    switch {
    case errors.Is(err, os.ErrNotExist):
        fmt.Println("config doesn't exist, using defaults")
    case err != nil:
        fmt.Println("other error:", err)
    }
}

Further reading

Errors as first-class values.