Error Handling in Depth
Prerequisites: Embedding & composition
Level 1 covered returning errors.New(...) and checking if err != nil.
Real programs also need to add context to errors as they travel up the
call stack, and to inspect them to decide what to do.
- Wrapping:
fmt.Errorf("loading config: %w", err)creates a new error whose message includes the original, while keeping the original reachable. - Sentinel errors are package-level values such as
io.EOFor your ownvar ErrNotFound = errors.New("not found"). Check for them witherrors.Is, which looks through every layer of wrapping. - Custom error types are any type with an
Error() stringmethod. Use them when callers need structured details; extract them witherrors.As.
Syntax
var ErrNotFound = errors.New("not found")
err := fmt.Errorf("find user %d: %w", id, ErrNotFound) // wrap with %w
errors.Is(err, ErrNotFound) // true: matches anywhere in the chain
var vErr *ValidationError
if errors.As(err, &vErr) { // finds a *ValidationError in the chain
fmt.Println(vErr.Field)
}
Example
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
var ErrNotFound = errors.New("not found")
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Msg
}
var users = map[int]string{1: "ada"}
func findUser(id int) (string, error) {
if id <= 0 {
return "", &ValidationError{Field: "id", Msg: "must be positive"}
}
name, ok := users[id]
if !ok {
return "", fmt.Errorf("find user %d: %w", id, ErrNotFound)
}
return name, nil
}
func main() {
for _, id := range []int{1, 2, -1} {
name, err := findUser(id)
var vErr *ValidationError
switch {
case err == nil:
fmt.Println("found:", name)
case errors.Is(err, ErrNotFound):
fmt.Println("missing:", err)
case errors.As(err, &vErr):
fmt.Println("invalid field", vErr.Field, "-", vErr.Msg)
default:
fmt.Println("unexpected:", err)
}
}
// The standard library uses the same pattern.
_, err := os.Open("does-not-exist.txt")
fmt.Println(errors.Is(err, fs.ErrNotExist))
}
Expected output
found: ada
missing: find user 2: not found
invalid field id - must be positive
true
%v instead of %w in fmt.Errorf produces the same message text but
breaks the chain, so errors.Is would no longer find ErrNotFound. To
combine several independent errors into one, use errors.Join(err1, err2).
Common mistakes
- Comparing with
==(err == ErrNotFound) after the error has been wrapped — it isfalse. Useerrors.Is. - Passing a value instead of a pointer to
errors.As. The second argument must be a pointer to a variable of the error type (here&vErr). - Returning a typed nil pointer as an
error(var e *ValidationError; return e). The caller sees a non-nilerror. Return a literalnilwhen there is no error. - Adding the same context at every level ("failed: failed: failed"), or both logging and returning the same error. Wrap with a short description of what this function was doing, and handle the error in one place.
Practice
Write divide(a, b int) (int, error) that returns a wrapped sentinel error
ErrDivideByZero when b is 0, with the message
divide 7 by 0: division by zero. In main, print the error and whether
errors.Is matches the sentinel.
Show solution
package main
import (
"errors"
"fmt"
)
var ErrDivideByZero = errors.New("division by zero")
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("divide %d by %d: %w", a, b, ErrDivideByZero)
}
return a / b, nil
}
func main() {
_, err := divide(7, 0)
fmt.Println(err) // divide 7 by 0: division by zero
fmt.Println(errors.Is(err, ErrDivideByZero)) // true
}