13 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).
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 %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:
- Direct equality (
err == target) - Custom
Is(error) boolmethod on the error - 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
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 |