Error Wrapping and Custom Errors Intermediate¶
Introduction¶
Go 1.13 introduced error wrapping — the ability to attach an underlying cause to an error while adding context. This gives you error chains you can inspect programmatically using errors.Is() and errors.As(). Combined with custom error types and sentinel errors, this system lets you build rich, inspectable error hierarchies without exceptions.
This builds on the Error Handling Basics topic. Here we go deeper: wrapping with %w, unwrapping, choosing between error strategies, and the errors.Join function added in Go 1.20.
Syntax & Usage¶
Wrapping Errors with %w¶
Use fmt.Errorf with the %w verb to wrap an error while adding context:
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("loading config from %s: %w", path, err)
}
The %w verb wraps the original error — the chain is preserved. Compare with %v, which only embeds the error's string:
fmt.Errorf("context: %v", err) // loses the original error — string only
fmt.Errorf("context: %w", err) // wraps the original — preserves the chain
errors.Is — Checking for Specific Errors¶
Walks the error chain looking for a match. Replaces the old == comparison.
_, err := os.Open("/nonexistent")
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file does not exist")
}
// Works through wrapped errors
wrapped := fmt.Errorf("config load failed: %w",
fmt.Errorf("opening file: %w", os.ErrNotExist))
errors.Is(wrapped, os.ErrNotExist) // true — walks the full chain
Why errors.Is instead of ==?
err == os.ErrNotExist // fails if err has been wrapped
errors.Is(err, os.ErrNotExist) // succeeds even through wrapping layers
errors.As — Extracting Error Types¶
Walks the chain to find an error matching a target type and extracts it.
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("operation: %s, path: %s\n", pathErr.Op, pathErr.Path)
}
errors.As replaces the old type assertion pattern:
// Old way — breaks on wrapped errors
if pathErr, ok := err.(*os.PathError); ok { ... }
// New way — walks the full error chain
var pathErr *os.PathError
if errors.As(err, &pathErr) { ... }
Creating Custom Error Types¶
Implement the error interface and optionally the Unwrap() method to participate in wrapping.
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}
func GetUser(id int) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("GetUser: %w", err)
}
if user == nil {
return nil, &NotFoundError{Resource: "user", ID: id}
}
return user, nil
}
// Caller extracts structured info
var nfe *NotFoundError
if errors.As(err, &nfe) {
fmt.Printf("missing %s: %d\n", nfe.Resource, nfe.ID)
}
Custom Error with Unwrap¶
To make your custom error participate in the wrapping chain, implement Unwrap():
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}
func (e *QueryError) Unwrap() error {
return e.Err
}
Now errors.Is and errors.As walk through QueryError to check the wrapped Err.
Sentinel Errors¶
Package-level error variables for well-known conditions:
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("resource conflict")
)
func GetItem(id string) (*Item, error) {
item, exists := store[id]
if !exists {
return nil, fmt.Errorf("item %s: %w", id, ErrNotFound)
}
return item, nil
}
// Caller
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", 404)
}
errors.Join (Go 1.20+)¶
Combines multiple errors into one. Useful when processing batches where multiple things can fail.
func validateUser(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if u.Age < 0 {
errs = append(errs, errors.New("age must be non-negative"))
}
return errors.Join(errs...) // returns nil if errs is empty
}
err := validateUser(User{})
fmt.Println(err)
// name is required
// email is required
// age must be non-negative
// errors.Is works against each joined error
errors.Is(err, someErr) // checks all joined errors
errors.Join returns an error whose Unwrap() method returns []error, enabling errors.Is and errors.As to check each constituent error.
Three Error Strategies — When to Use Each¶
// 1. Sentinel errors — caller checks identity
var ErrNotFound = errors.New("not found")
// Use: well-known conditions, public API contracts
// 2. Custom error types — caller extracts structured data
type ValidationError struct { Field, Message string }
// Use: errors that carry context the caller needs to inspect
// 3. Opaque errors — caller only checks nil/non-nil
return fmt.Errorf("internal failure: %w", err)
// Use: internal implementation details, no special handling expected
| Strategy | Caller can... | Expose to external callers? |
|---|---|---|
| Sentinel errors | errors.Is(err, ErrX) |
Yes — part of your API |
| Custom error types | errors.As(err, &target) |
Yes — part of your API |
| Opaque errors | Check err != nil only |
No — internal detail |
Quick Reference¶
| Function | Purpose | Example |
|---|---|---|
fmt.Errorf("...: %w", err) |
Wrap error with context | fmt.Errorf("open db: %w", err) |
errors.Is(err, target) |
Check if error chain contains target | errors.Is(err, os.ErrNotExist) |
errors.As(err, &target) |
Extract typed error from chain | errors.As(err, &pathErr) |
errors.Unwrap(err) |
Get one level of wrapped error | errors.Unwrap(wrappedErr) |
errors.New("msg") |
Create simple sentinel error | var ErrNotFound = errors.New(...) |
errors.Join(errs...) |
Combine multiple errors (1.20+) | errors.Join(err1, err2) |
Best Practices¶
- Use
%wwhen callers need to inspect the cause — this is part of your API contract. Use%vwhen wrapping internal errors you don't want to expose. - Add context at each layer —
fmt.Errorf("parsing config: %w", err)builds a readable chain:"parsing config: opening file: permission denied". - Use
errors.Isinstead of==— it handles wrapped errors correctly. - Use
errors.Asinstead of type assertions — it walks the full chain. - Prefer sentinel errors for well-known conditions —
ErrNotFound,ErrTimeout. Callers depend on these; they're your error API. - Use custom types when errors carry data — HTTP status codes, field names, retry information.
- Don't wrap errors you didn't create — if you're just passing through,
return erris fine. Wrapping adds value only when you add context.
Common Pitfalls¶
Using %v instead of %w
// BUG: original error is lost — errors.Is won't find it
return fmt.Errorf("opening file: %v", err)
// FIX: use %w to preserve the chain
return fmt.Errorf("opening file: %w", err)
%v, the original error becomes just a string embedded in the message. errors.Is and errors.As can no longer find it.
Wrapping the same error multiple times
Comparing wrapped errors with ==
Exposing internal errors in public APIs
// WRONG: callers now depend on your internal DB library's error types
func GetUser(id int) (*User, error) {
return nil, fmt.Errorf("GetUser: %w", pgx.ErrNoRows) // leaks pgx
}
// RIGHT: translate to your own sentinel
var ErrNotFound = errors.New("not found")
func GetUser(id int) (*User, error) {
_, err := db.Query(...)
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("querying user %d: %w", id, err)
}
%w makes the wrapped error part of your public API. Be intentional about what you expose.
Implementing Is() or As() incorrectly on custom types
Custom error types can implement Is(target error) bool and As(target any) bool for custom matching logic. If you do this, ensure correctness — returning true for the wrong type breaks the entire chain inspection.
Performance Considerations¶
- Error creation is cheap —
fmt.Errorfwith%wallocates a small wrapper struct. Don't avoid wrapping for performance. errors.Isanderrors.Aswalk the chain linearly — O(n) in chain depth. Typical chains are 3–5 deep, so this is negligible.- String formatting in errors —
fmt.Errorfformats on creation, not onError()call. If you create errors on a hot path that are rarely printed, consider lazy formatting. errors.Join— allocates a slice. For single errors, direct wrapping with%wis cheaper.
Interview Tips¶
Interview Tip
"What's the difference between %v and %w in fmt.Errorf?" %v embeds the error's string — the original error is lost. %w wraps the original error, preserving it in a chain that errors.Is and errors.As can traverse. Use %w when callers need to inspect the cause; use %v to hide internal details.
Interview Tip
"When would you use errors.Is vs errors.As?" errors.Is checks for a specific value (sentinel errors like os.ErrNotExist). errors.As checks for a specific type and extracts it (custom error types like *os.PathError). Think: Is = identity, As = type extraction.
Interview Tip
"How do you decide between sentinel errors and custom error types?" Sentinel errors are simple identity checks — ErrNotFound, ErrTimeout. Use them for well-known conditions where the caller just needs to know what happened. Custom error types carry structured data — field names, HTTP codes, retry hints. Use them when the caller needs details about why it happened.
Interview Tip
"What is errors.Join and when would you use it?" Added in Go 1.20, errors.Join combines multiple errors into one. It's useful for batch validation, parallel operations, or any case where multiple things can fail independently. The joined error supports errors.Is and errors.As against each constituent error.
Key Takeaways¶
- Use
%winfmt.Errorfto wrap errors while preserving the chain for inspection. errors.Iswalks the chain to check for a specific error value — replaces==.errors.Aswalks the chain to extract a specific error type — replaces type assertions.- Sentinel errors (
var ErrX = errors.New(...)) define your error API contract. - Custom error types carry structured data and implement
Error() string. errors.Join(Go 1.20+) combines multiple errors into a single inspectable error.- Be intentional about what you wrap with
%w— wrapped errors become part of your public API.