Error Handling
Error handling patterns including wrapping, sentinel errors, custom types, and error groups in Go
You are an expert in Go error handling, helping developers write robust code with clear, actionable error chains using idiomatic Go patterns.
## Key Points
- Always handle errors; never discard them with `_` unless you have a documented reason.
- Wrap errors with `fmt.Errorf("context: %w", err)` to build a chain of context.
- Use `errors.Is` for sentinel comparison and `errors.As` for type extraction.
- Add context about *what* the function was doing, not *that* it failed: `"opening config file: %w"` not `"error: %w"`.
- Use sentinel errors (`ErrNotFound`) for expected conditions callers should branch on.
- Use custom error types when callers need structured data from the error.
- Log errors at the top of the call stack, not at every level (avoid duplicate logs).
- **Comparing errors with `==`**: use `errors.Is` instead, which traverses the wrap chain.
- **Using `%v` instead of `%w`**: `%v` formats the error but does not wrap it, breaking `errors.Is`/`errors.As`.
- **Wrapping then re-creating**: `fmt.Errorf("msg: %w", fmt.Errorf("inner"))` is fine, but `errors.New(err.Error())` discards the chain.
- **Returning a typed nil**: `func f() *MyError { return nil }` returned as `error` produces a non-nil interface. Return `error(nil)` explicitly.
- **Panic for control flow**: reserve `panic` for truly unrecoverable situations (e.g., programmer bugs). Use `error` for everything else.
## Quick Example
```go
type error interface {
Error() string
}
```
```go
var (
ErrNotFound = errors.New("not found")
ErrForbidden = errors.New("forbidden")
)
```skilldb get go-skills/Error HandlingFull skill: 209 linesError Handling — Go Programming
You are an expert in Go error handling, helping developers write robust code with clear, actionable error chains using idiomatic Go patterns.
Core Philosophy
Go's error handling is built on a radical premise: errors are not exceptional. They are ordinary values, returned like any other result, and handled with ordinary control flow. There are no try-catch blocks, no exception hierarchies, no stack unwinding. This forces you to confront every failure point explicitly at the call site, which makes error handling verbose but also makes the failure paths of your program visible and reviewable in code review.
The wrapping system introduced in Go 1.13 transforms errors from opaque strings into structured chains. When you write fmt.Errorf("opening config: %w", err), you are building a linked list of context that tells the story of what went wrong — not just "file not found" but "opening config: reading /etc/app.yaml: file not found." This chain is the error's provenance, and errors.Is and errors.As let callers inspect it without string parsing. The wrap chain is your program's error narrative; treat it with the same care you would give to log messages.
The discipline of error handling in Go comes down to a simple rule: handle it or wrap it, but never ignore it. Every if err != nil block is a decision point where you either deal with the error (retry, return a default, log and move on) or add context and pass it up. The worst thing you can do is swallow an error silently, because that turns a diagnosable failure into a mysterious misbehavior discovered hours later in production.
Anti-Patterns
-
Logging and returning the same error: When every layer in the call stack logs the error before returning it, you get five log lines for one failure, none of which tell you the full story. Log errors at the top of the call stack where you have the full context; wrap them everywhere else.
-
Wrapping with redundant context: Writing
fmt.Errorf("error getting user: %w", err)whenerralready says "getting user" adds noise. Each wrap should add new information — what this layer was trying to do, which input triggered the failure, what resource was involved. -
Using
panicfor expected failures: Network timeouts, missing files, invalid input — these are not panics. Reservepanicfor programmer bugs like nil pointer dereferences in code that should be unreachable. If a caller can reasonably be expected to handle the failure, return anerror. -
Matching errors by string content: Calling
strings.Contains(err.Error(), "not found")is fragile and breaks when error messages change. Define sentinel errors or custom error types, and useerrors.Isorerrors.Asfor matching. -
Creating error types with unexported fields but no
Unwrapmethod: If your custom error wraps an underlying error but does not implementUnwrap() error, thenerrors.Isanderrors.Ascannot traverse the chain. Always implementUnwrapwhen your error type contains a cause.
Overview
Go treats errors as values. Functions return an error as the last return value, and callers check it explicitly. Since Go 1.13, the errors and fmt packages support wrapping and unwrapping errors, enabling rich error chains without third-party libraries.
Core Concepts
The error Interface
type error interface {
Error() string
}
Sentinel Errors
Package-level variables for known error conditions.
var (
ErrNotFound = errors.New("not found")
ErrForbidden = errors.New("forbidden")
)
Error Wrapping (Go 1.13+)
// Wrap with context
return fmt.Errorf("fetching user %d: %w", id, err)
// Check wrapped errors
if errors.Is(err, ErrNotFound) { ... }
// Extract typed errors
var pathErr *os.PathError
if errors.As(err, &pathErr) { ... }
Implementation Patterns
Custom Error Types
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
func Validate(u User) error {
if u.Email == "" {
return &ValidationError{Field: "email", Message: "required"}
}
return nil
}
// Caller
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("field %s: %s", ve.Field, ve.Message)
}
Multi-Error Collection
type MultiError struct {
Errors []error
}
func (m *MultiError) Error() string {
msgs := make([]string, len(m.Errors))
for i, e := range m.Errors {
msgs[i] = e.Error()
}
return strings.Join(msgs, "; ")
}
// Or use errors.Join (Go 1.20+)
err := errors.Join(err1, err2, err3)
Error Handling in HTTP Handlers
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error { return e.Err }
type appHandler func(w http.ResponseWriter, r *http.Request) error
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
http.Error(w, appErr.Message, appErr.Code)
} else {
http.Error(w, "internal error", http.StatusInternalServerError)
}
slog.Error("handler error", "err", err)
}
}
Errgroup for Concurrent Error Handling
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]string, len(urls))
for i, url := range urls {
g.Go(func() error {
body, err := fetch(ctx, url)
if err != nil {
return fmt.Errorf("fetching %s: %w", url, err)
}
results[i] = body
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
Retry with Backoff
func withRetry(ctx context.Context, maxAttempts int, fn func() error) error {
var err error
for attempt := 0; attempt < maxAttempts; attempt++ {
err = fn()
if err == nil {
return nil
}
// Don't retry non-transient errors
if errors.Is(err, ErrNotFound) {
return err
}
delay := time.Duration(1<<attempt) * 100 * time.Millisecond
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("after %d attempts: %w", maxAttempts, err)
}
Best Practices
- Always handle errors; never discard them with
_unless you have a documented reason. - Wrap errors with
fmt.Errorf("context: %w", err)to build a chain of context. - Use
errors.Isfor sentinel comparison anderrors.Asfor type extraction. - Add context about what the function was doing, not that it failed:
"opening config file: %w"not"error: %w". - Use sentinel errors (
ErrNotFound) for expected conditions callers should branch on. - Use custom error types when callers need structured data from the error.
- Log errors at the top of the call stack, not at every level (avoid duplicate logs).
Common Pitfalls
- Comparing errors with
==: useerrors.Isinstead, which traverses the wrap chain. - Using
%vinstead of%w:%vformats the error but does not wrap it, breakingerrors.Is/errors.As. - Wrapping then re-creating:
fmt.Errorf("msg: %w", fmt.Errorf("inner"))is fine, buterrors.New(err.Error())discards the chain. - Returning a typed nil:
func f() *MyError { return nil }returned aserrorproduces a non-nil interface. Returnerror(nil)explicitly. - Panic for control flow: reserve
panicfor truly unrecoverable situations (e.g., programmer bugs). Useerrorfor everything else.
Install this skill directly: skilldb add go-skills
Related Skills
Context Patterns
Context usage for cancellation, timeouts, deadlines, and value propagation in Go
Generics
Go generics including type parameters, constraints, and generic data structure patterns (Go 1.18+)
Goroutines Channels
Concurrency patterns using goroutines, channels, select, and sync primitives in Go
HTTP Servers
HTTP server patterns using net/http, chi, and gin including middleware, routing, and graceful shutdown
Interfaces
Interface design principles, implicit satisfaction, and composition patterns in Go
Modules
Go modules, dependency management, versioning, workspaces, and private module configuration