| name | error-handling |
| description | Error handling patterns for Go 1.20+. Covers errors.Is, errors.As, errors.Join, sentinel errors, error wrapping, multi-error, slog error logging, defer cleanup, error types vs sentinels, performance traps. |
Error Handling
Go 1.20+: Multi-Error Support
errors.Join (Go 1.20+)
Combine multiple errors without losing any:
var errs []error
for result := range results {
if result.err != nil {
errs = append(errs, result.err)
}
}
return errors.Join(errs...)
Multiple %w in fmt.Errorf (Go 1.20+)
Wrap multiple errors with context:
err := fmt.Errorf("operation failed: %w and %w", err1, err2)
Both errors.Is(err, err1) and errors.Is(err, err2) return true.
Critical: Unwrap Behavior Change
wrapped := errors.Unwrap(joinedErr)
if errors.Is(joinedErr, targetErr) { ... }
if errors.As(joinedErr, &typedErr) { ... }
errors.Unwrap() returns nil for multi-wrapped errors. Always use errors.Is/errors.As.
Sources: Go 1.20 Multi-Error Wrapping, errors package
Performance: Sentinel Errors Are Slow
Benchmarks from DoltHub:
| Pattern | ns/op | vs baseline |
|---|
| Boolean return | 3.4 | 1x |
Direct == check | 7.4 | 2x |
errors.Is() | 19.4 | 6x |
| Wrapped sentinel through stack | 1,374 | 117x |
When Performance Matters
Don't use sentinel errors for expected conditions:
var ErrNotFound = errors.New("not found")
func Get(key string) (Value, error) {
if !exists {
return Value{}, ErrNotFound
}
}
func Get(key string) (Value, bool) {
if !exists {
return Value{}, false
}
}
Reserve errors for unexpected failures. Expected outcomes (not found, empty, EOF) should use booleans or special values.
If You Must Use Sentinel Errors
Check nil first to avoid errors.Is overhead:
if err != nil {
if errors.Is(err, ErrSpecific) {
}
}
Project Patterns
Error Message Format
Use lowercase, action-oriented prefixes:
return fmt.Errorf("upstream fetch: %w", err)
return fmt.Errorf("render markdown: %w", err)
return fmt.Errorf("parse upstream url: %w", err)
return fmt.Errorf("cache store: %w", err)
return fmt.Errorf("read response body: size exceeds %d bytes", maxSize)
When wrapping, preserve the context chain:
if err := fetchUpstream(url); err != nil {
return fmt.Errorf("render %s: %w", url, err)
}
slog Error Logging
Always use "error" as the key, error as value:
slog.Error("upstream fetch failed", "error", err, "upstream", url)
slog.Warn("cache eviction", "error", err, "key", cacheKey)
Never stringify the error:
slog.Error("failed", "error", err.Error())
slog.Error("failed", "error", err)
HTTP Error Responses
cooked returns styled HTML error pages:
func renderError(w http.ResponseWriter, status int, errType, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
}
Type Assertions with errors.As
For stdlib error types:
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
}
Decision Tree
Returning Errors
Is the condition expected (not found, empty, no match)?
YES → Return (value, bool) or special value, not error
NO → Continue
Need to add context?
YES → fmt.Errorf("context: %w", err)
NO → return err
Multiple errors to combine?
YES → errors.Join(errs...) or fmt.Errorf("%w and %w", e1, e2)
Checking Errors
Need to check specific error type?
YES → errors.As(err, &typed)
Need to check sentinel error?
YES → if err != nil && errors.Is(err, sentinel)
Just need to know if error occurred?
YES → if err != nil
Logging vs Returning
Can the caller handle this error?
YES → Return it, don't log
NO → Log it, then either:
- Return a wrapped/sanitized error
- Handle and continue
At system boundary (HTTP handler, main)?
YES → Log with full context, return user-safe message
Error Types vs Sentinels
| Use | When |
|---|
Sentinel (var ErrX = errors.New(...)) | Well-known conditions: io.EOF, context.Canceled |
Error type (type MyError struct{...}) | Error carries structured data callers need |
Wrapped sentinel (fmt.Errorf("...: %w", ErrX)) | Need both context and identifiable cause |
Plain error (fmt.Errorf("...")) | Caller doesn't need to distinguish error types |
Default to plain errors. Only add sentinels/types when callers need to handle specifically.
Defer Cleanup Pattern
Handle errors from deferred cleanup:
func DoWork() (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
return nil
}