Files
go-patterns/patterns/error-handling.md
T
aweiker eb9171368b docs: add 'when to use' triggers + examples to all patterns
Added 'When to Use' subsections with concrete decision triggers and
before/after Go code examples to patterns across all directories:

- patterns/error-handling.md (3 patterns: sentinels, wrapping, Join)
- patterns/concurrency.md (4 patterns: Mutex, Once, done channels, pipelines)
- patterns/interfaces.md (4 patterns: small interfaces, accept/return, adapter, optional)
- patterns/structs.md (3 patterns: zero-value, constructors, config structs)
- patterns/package-design.md (3 patterns: internal/, init(), context keys)
- patterns/style.md (3 patterns: interface checks, iota constants, named types)
- patterns/testing-advanced.md (3 patterns: table tests, golden files, httptest)
- patterns/api-conventions.md (3 patterns: Must, layered API, graceful shutdown)
- patterns/documentation.md (2 patterns: examples, deprecated)
- kubernetes/patterns.md (3 patterns: controller, workqueue, leader election)
- kubernetes/production-go.md (2 patterns: codegen, HandleCrash)
- smells/anti-patterns.md (2 anti-patterns: cache mutation, edge-triggered)
2026-04-30 12:08:41 +00:00

16 KiB

Go Error Handling Patterns

Patterns extracted from the Go standard library source code.


1. Sentinel Errors

Source: src/io/io.go:40-43 (EOF), src/errors/errors.go:81-83 (ErrUnsupported)

// src/io/io.go:40-43
// EOF is the error returned by Read when no more input is available.
// (Read must return EOF itself, not an error wrapping EOF,
// because callers will test for EOF using ==.)
var EOF = errors.New("EOF")

// src/io/io.go:47-49
var ErrUnexpectedEOF = errors.New("unexpected EOF")
// src/errors/errors.go:81-83
var ErrUnsupported = New("unsupported operation")

Why

Sentinel errors are package-level values that represent specific, well-known error conditions. They enable callers to test for specific failures:

if err == io.EOF {
    // end of input — not an error, just done
}

Critical rule from io.EOF's doc comment: Read must return EOF itself, not an error wrapping EOF, because callers test for it with ==. This is the distinction between sentinel errors (identity-checked) and wrapped errors (tree-checked).

When to Use

Triggers:

  • You have a specific, well-known failure condition callers need to check by identity
  • Multiple packages compare against the same error value (io.EOF, sql.ErrNoRows)
  • The error represents a state ("end of stream", "not found"), not a bug

Example — before:

func fetchUser(id int) (*User, error) {
    row := db.QueryRow("SELECT ...")
    var u User
    err := row.Scan(&u.Name)
    if err != nil {
        return nil, fmt.Errorf("user not found") // caller can't distinguish "not found" from "db down"
    }
    return &u, nil
}

Example — after:

var ErrUserNotFound = errors.New("users: not found")

func fetchUser(id int) (*User, error) {
    row := db.QueryRow("SELECT ...")
    var u User
    err := row.Scan(&u.Name)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrUserNotFound // sentinel: callers can test with errors.Is
    }
    if err != nil {
        return nil, fmt.Errorf("fetchUser: %w", err)
    }
    return &u, nil
}

Anti-pattern

// DON'T: Use string matching
if err.Error() == "EOF" { ... }  // fragile, not guaranteed

// DON'T: Return a new error each time for sentinel conditions
func Read() error {
    return errors.New("EOF")  // every call creates a new value, can't compare with ==
}

2. errors.New — Minimal Error Construction

Source: src/errors/errors.go:62-69

// src/errors/errors.go:62-64
func New(text string) error {
    return &errorString{text}
}

// src/errors/errors.go:66-69
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

Why

errors.New returns a pointer to a private struct. Each call creates a distinct value even with identical text — this is intentional for sentinel errors. Two calls to errors.New("foo") produce different errors (!=).

The error interface itself is the smallest possible:

type error interface {
    Error() string
}

Anti-pattern

// DON'T: Export the error type
type MyError string  // callers can create values that accidentally == your sentinels

// DON'T: Use plain strings as errors
func doThing() error {
    return "something failed"  // doesn't implement error interface
}

3. Error Wrapping with fmt.Errorf and %w

Source: src/fmt/errors.go:13-23, src/fmt/errors.go:70-80

// src/fmt/errors.go:13-23
// Errorf formats according to a format specifier and returns the string
// as a value that satisfies error.
//
// If the format specifier includes a %w verb with an error operand,
// the returned error will implement an Unwrap method returning the operand.
// If there is more than one %w verb, the returned error will implement an
// Unwrap method returning a []error containing all the %w operands.
func Errorf(format string, a ...any) (err error) { ... }

// src/fmt/errors.go:70-80
type wrapError struct {
    msg string
    err error
}

func (e *wrapError) Error() string {
    return e.msg
}

func (e *wrapError) Unwrap() error {
    return e.err
}

Why

%w creates an error chain: the returned error wraps the original. errors.Is and errors.As walk this chain. Use %w when callers should be able to inspect the underlying cause.

// Wraps: callers can detect the original error
return fmt.Errorf("open config: %w", err)

// Does NOT wrap: hides the original error
return fmt.Errorf("open config: %v", err)

When to Use

Triggers:

  • You're adding context to an error before returning it up the call stack
  • The caller's error message would be meaningless without knowing what operation failed
  • You have a chain of function calls and want a readable error trail: "open config: read file: permission denied"

Example — before:

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err // caller sees "open /etc/app.conf: permission denied" — no context about WHO called ReadFile
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err // caller can't tell if this was a read error or a parse error
    }
    return &cfg, nil
}

Example — after:

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("load config: %w", err) // wraps: callers can errors.Is(err, os.ErrNotExist)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("load config: parse %s: %v", path, err) // %v: hides internal JSON error type
    }
    return &cfg, nil
}

When to use %w vs %v

  • %w: When the wrapped error is part of your API contract. Callers can depend on it.
  • %v: When you want to include the error text but NOT let callers depend on the underlying type. Use for implementation details.

Anti-pattern

// DON'T: Lose the original error
return errors.New("failed to open config")  // original error vanished

// DON'T: Wrap errors that aren't part of your contract with %w
return fmt.Errorf("internal: %w", internalErr)  // now callers depend on internalErr's type

4. errors.Is — Checking Error Identity Through Chains

Source: src/errors/wrap.go:30-44

// src/errors/wrap.go:30-44
func Is(err, target error) bool {
    if err == nil || target == nil {
        return err == target
    }
    isComparable := reflectlite.TypeOf(target).Comparable()
    return is(err, target, isComparable)
}

func is(err, target error, targetComparable bool) bool {
    for {
        if targetComparable && err == target {
            return true
        }
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        switch x := err.(type) {
        case interface{ Unwrap() error }:
            err = x.Unwrap()
            if err == nil {
                return false
            }
        case interface{ Unwrap() []error }:
            for _, err := range x.Unwrap() {
                if is(err, target, targetComparable) {
                    return true
                }
            }
            return false
        default:
            return false
        }
    }
}

Why

errors.Is walks the entire error tree (depth-first). It checks:

  1. Direct equality (err == target)
  2. Custom Is(error) bool method on the error
  3. Then unwraps and recurses

This means wrapped errors are transparent:

err := fmt.Errorf("config: %w", os.ErrNotExist)
errors.Is(err, os.ErrNotExist)  // true! walks the chain

Anti-pattern

// DON'T: Use == directly on potentially-wrapped errors
if err == os.ErrNotExist { ... }  // fails if err wraps ErrNotExist

// DO: Use errors.Is
if errors.Is(err, os.ErrNotExist) { ... }  // works through wrapping

5. errors.As — Extracting Error Types Through Chains

Source: src/errors/wrap.go:96-120

// src/errors/wrap.go:96-120
func As(err error, target any) bool {
    if err == nil {
        return false
    }
    // ... validation ...
    targetType := typ.Elem()
    return as(err, target, val, targetType)
}

The as function (line 121+) walks the tree checking AssignableTo and custom As(any) bool methods.

Why

Extract specific error types from wrapped chains:

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    fmt.Println("failed path:", pathErr.Path)
}

Go 1.24+: errors.AsType (generic version)

From src/errors/errors.go:48-56 doc:

if perr, ok := errors.AsType[*fs.PathError](err); ok {
    fmt.Println(perr.Path)
}

Anti-pattern

// DON'T: Type-assert directly on potentially-wrapped errors
if pathErr, ok := err.(*fs.PathError); ok { ... }  // fails if wrapped

// DO: Use errors.As
var pathErr *fs.PathError
if errors.As(err, &pathErr) { ... }  // works through wrapping

6. errors.Join — Multi-Error Aggregation

Source: src/errors/join.go:20-39

// src/errors/join.go:20-39
func Join(errs ...error) error {
    n := 0
    for _, err := range errs {
        if err != nil {
            n++
        }
    }
    if n == 0 {
        return nil
    }
    e := &joinError{
        errs: make([]error, 0, n),
    }
    for _, err := range errs {
        if err != nil {
            e.errs = append(e.errs, err)
        }
    }
    return e
}

The joinError type implements Unwrap() []error, making both Is and As traverse correctly.

Why

For operations that can produce multiple errors (closing multiple resources, validating multiple fields), Join collects them into a single error.

var errs []error
errs = append(errs, closeDB())
errs = append(errs, closeCache())
return errors.Join(errs...)  // nil if all nil

When to Use

Triggers:

  • You're closing/cleaning up multiple resources and each can fail independently
  • A validation function checks multiple fields and you want ALL errors, not just the first
  • You're running parallel operations and collecting errors from each

Example — before:

func cleanup(db *sql.DB, cache *redis.Client, file *os.File) error {
    if err := db.Close(); err != nil {
        return err // stops here — cache and file leak!
    }
    if err := cache.Close(); err != nil {
        return err // file still leaks
    }
    return file.Close()
}

Example — after:

func cleanup(db *sql.DB, cache *redis.Client, file *os.File) error {
    return errors.Join(
        db.Close(),
        cache.Close(),
        file.Close(),
    ) // nil if all nil; contains all failures otherwise
}

Anti-pattern

// DON'T: Return only the last error
var lastErr error
for _, r := range resources {
    if err := r.Close(); err != nil {
        lastErr = err  // silently loses previous errors
    }
}
return lastErr

7. Custom Is() Method — Equivalence Classes

Source: src/errors/wrap.go:42-44 (doc comment), src/context/context.go:177-179

From the errors.Is doc:

// An error type might provide an Is method so it can be treated as
// equivalent to an existing error. For example, if MyError defines
//
//   func (m MyError) Is(target error) bool { return target == fs.ErrExist }
//
// then Is(MyError{}, fs.ErrExist) returns true.

Real example from context:

// src/context/context.go:177-179
type deadlineExceededError struct{}

func (deadlineExceededError) Error() string   { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool   { return true }
func (deadlineExceededError) Temporary() bool { return true }

Why

Custom Is methods let you define error equivalence beyond pointer identity. A syscall.Errno can match fs.ErrExist through its Is method, bridging OS-specific error codes to portable sentinel errors.

Anti-pattern

// DON'T: Make Is() too broad
func (e MyError) Is(target error) bool {
    return true  // matches everything — defeats the purpose
}

8. Error Wrapping in Custom Types (Unwrap pattern)

Source: src/encoding/json/encode.go:276-293

// src/encoding/json/encode.go:276-282
type MarshalerError struct {
    Type       reflect.Type
    Err        error
    sourceFunc string
}

// src/encoding/json/encode.go:293
func (e *MarshalerError) Unwrap() error { return e.Err }

Why

Custom error types carry structured data (which type failed, which function) while still participating in the error chain via Unwrap(). Callers can use errors.As to extract the MarshalerError AND use errors.Is to check the underlying cause.

Pattern Template

type OpError struct {
    Op   string
    Path string
    Err  error
}

func (e *OpError) Error() string {
    return e.Op + " " + e.Path + ": " + e.Err.Error()
}

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

Anti-pattern

// DON'T: Store error as string
type MyError struct {
    Message string  // lost the original error!
}

// DON'T: Forget to implement Unwrap
type MyError struct {
    Err error  // has the error but errors.Is can't traverse it
}
func (e *MyError) Error() string { return e.Err.Error() }
// Missing: func (e *MyError) Unwrap() error { return e.Err }

9. ErrUnsupported — Feature Detection via Errors

Source: src/errors/errors.go:76-83

// src/errors/errors.go:76-83
// ErrUnsupported indicates that a requested operation cannot be performed,
// because it is unsupported.
//
// Functions and methods should not return this error but should instead
// return an error including appropriate context that satisfies
//
//   errors.Is(err, errors.ErrUnsupported)
//
// either by directly wrapping ErrUnsupported or by implementing an Is method.
var ErrUnsupported = New("unsupported operation")

Why

This pattern separates "what happened" (detailed context) from "what kind of failure" (sentinel identity). Return a rich error that wraps or matches the sentinel:

return fmt.Errorf("chmod %s: %w", path, errors.ErrUnsupported)

Anti-pattern

// DON'T: Return the sentinel directly without context
return errors.ErrUnsupported  // no info about what operation or why

10. Error String Conventions

Source: src/net/http/server.go:39-56

// src/net/http/server.go:39-56
var (
    ErrHijacked       = errors.New("http: connection has been hijacked")
    ErrContentLength  = errors.New("http: wrote more than the declared Content-Length")
)

Convention: Error String Format

package: description
  • Lowercase (no capital first letter)
  • No trailing punctuation
  • Package prefix for disambiguation

Anti-pattern

// DON'T: Capitalize error strings
errors.New("Connection has been hijacked")

// DON'T: End with punctuation
errors.New("connection failed.")

// DON'T: Include redundant "error" word
errors.New("http error: connection failed")  // it's already an error

Summary: Error Handling Decision Tree

Is this a specific, well-known condition?
├── YES → Sentinel error (package-level var)
│         └── Should callers detect it? → errors.Is
└── NO → Is there structured info to convey?
         ├── YES → Custom error type with Unwrap()
         │         └── Should callers extract it? → errors.As
         └── NO → fmt.Errorf with %w (wraps) or %v (doesn't wrap)
When to... Use
Create a well-known error condition var ErrFoo = errors.New("pkg: foo")
Add context while preserving cause fmt.Errorf("doing X: %w", err)
Add context, hide internal cause fmt.Errorf("doing X: %v", err)
Check for a specific condition errors.Is(err, ErrFoo)
Extract structured error data errors.As(err, &target)
Aggregate multiple errors errors.Join(err1, err2)
Make custom types traversable Implement Unwrap() error
Define error equivalence Implement Is(error) bool