Files
go-patterns/patterns/error-handling.md
T

745 lines
20 KiB
Markdown

# 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)
```go
// 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")
```
```go
// 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:
```go
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:**
```go
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:**
```go
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
}
```
### When NOT to Use
**Don't use this when:**
- The error condition is internal and no caller should branch on it — use `fmt.Errorf` instead
- You have too many sentinels (>5-6 per package) — consider a custom error type with a code field
- The error carries varying context (file path, user ID) — sentinels are for fixed conditions
**Over-application example:**
```go
// A sentinel for every possible failure — explosion of package-level vars
var (
ErrUserNotFound = errors.New("users: not found")
ErrUserInactive = errors.New("users: inactive")
ErrUserSuspended = errors.New("users: suspended")
ErrUserRateLimited = errors.New("users: rate limited")
ErrUserInvalidEmail = errors.New("users: invalid email")
ErrUserInvalidName = errors.New("users: invalid name")
ErrUserInvalidAge = errors.New("users: invalid age")
// 20 more...
)
```
**Better alternative:**
```go
// Use a typed error with a code when you have many distinct conditions
type UserError struct {
Code string // "not_found", "inactive", "suspended"
Field string // which field failed validation
Message string
}
func (e *UserError) Error() string { return "users: " + e.Message }
// Callers use errors.As to inspect
var uerr *UserError
if errors.As(err, &uerr) && uerr.Code == "not_found" { ... }
```
**Why:** Sentinels are for a small number of well-known states that callers frequently branch on. If you're creating dozens, you've outgrown the pattern — a structured error type with an enum/code field scales better and avoids polluting the package namespace.
### Anti-pattern
```go
// 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`
```go
// 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:
```go
type error interface {
Error() string
}
```
### Anti-pattern
```go
// 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`
```go
// 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.
```go
// 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:**
```go
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:**
```go
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.
### When NOT to Use
**Don't use this when:**
- You're wrapping at every single layer — the error message becomes `"a: b: c: d: e: permission denied"`
- The added context is obvious from the function name (the caller already knows what function they called)
- You're wrapping errors from a dependency you don't want in your API contract (use `%v` instead)
**Over-application example:**
```go
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
user, err := s.getUser(r)
if err != nil {
// "handleRequest: getUser: fetchFromDB: queryRow: scan: sql: no rows"
// The handler name adds nothing — the caller IS the handler
http.Error(w, fmt.Errorf("handleRequest: %w", err).Error(), 500)
return
}
}
```
**Better alternative:**
```go
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
user, err := s.getUser(r)
if err != nil {
// Log the full chain internally, return a clean message to the client
slog.Error("fetching user", "err", err, "path", r.URL.Path)
http.Error(w, "internal error", 500)
return
}
}
```
**Why:** Wrapping should add *meaningful* context that isn't already obvious. At HTTP handler boundaries, you typically log the error (with full chain) and return a generic response. Wrapping at every layer creates unreadable error strings and leaks internals.
### Anti-pattern
```go
// 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`
```go
// 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:
```go
err := fmt.Errorf("config: %w", os.ErrNotExist)
errors.Is(err, os.ErrNotExist) // true! walks the chain
```
### Anti-pattern
```go
// 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`
```go
// 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:
```go
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:
```go
if perr, ok := errors.AsType[*fs.PathError](err); ok {
fmt.Println(perr.Path)
}
```
### Anti-pattern
```go
// 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`
```go
// 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.
```go
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:**
```go
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:**
```go
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
}
```
### When NOT to Use
**Don't use this when:**
- Errors are causally related (error B was caused by error A) — use wrapping (`%w`) instead
- You're collecting errors across retries of the *same* operation — return the last error or wrap them causally
- The caller needs to distinguish which resource failed — Join loses that information
**Over-application example:**
```go
func fetchWithRetry(url string, attempts int) error {
var errs []error
for i := 0; i < attempts; i++ {
err := fetch(url)
if err == nil {
return nil
}
errs = append(errs, err) // collecting retry errors — misleading
}
return errors.Join(errs...) // caller sees 3 errors but they're the SAME operation
}
```
**Better alternative:**
```go
func fetchWithRetry(url string, attempts int) error {
var lastErr error
for i := 0; i < attempts; i++ {
lastErr = fetch(url)
if lastErr == nil {
return nil
}
}
return fmt.Errorf("fetch %s: %d attempts failed, last: %w", url, attempts, lastErr)
}
```
**Why:** `errors.Join` is for *independent* failures that all matter equally. For retries, the caller cares about the final failure and the retry count, not a list of (often identical) errors. For causal chains, wrapping preserves the relationship between cause and effect.
### Anti-pattern
```go
// 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:
```go
// 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:
```go
// 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
```go
// 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`
```go
// 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
```go
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
```go
// 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`
```go
// 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:
```go
return fmt.Errorf("chmod %s: %w", path, errors.ErrUnsupported)
```
### Anti-pattern
```go
// 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`
```go
// 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
```go
// 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` |