Errors, defer and panic
The error interface, wrapping with fmt.Errorf and %w, errors.Is and errors.As, sentinel and custom types, defer ordering, and legitimate recover.
Error values and wrapping
var ErrNotFound = errors.New("not found") // sentinel: compare with errors.Is
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
func loadUser(path, id string) (User, error) {
data, err := os.ReadFile(path)
if err != nil {
// %w keeps the original error in the chain
return User{}, fmt.Errorf("load user %q from %s: %w", id, path, err)
}
if len(data) == 0 {
return User{}, fmt.Errorf("user %q: %w", id, ErrNotFound)
}
return parse(data), nil
}| Kind | Declared as | Checked with |
|---|---|---|
| Sentinel | var ErrNotFound = errors.New(...) | errors.Is(err, ErrNotFound) |
| Custom type | type ValidationError struct{...} with an Error() method | errors.As(err, &target) |
| Wrapped | fmt.Errorf("...: %w", err) | errors.Is / errors.As through the chain |
| Opaque | fmt.Errorf("...: %v", err) | Only the text survives; the type is gone |
- Return errors, do not log and return. Logging at every level produces the same failure five times in the logs; log once, at the boundary that handles it.
- Add context on the way up and keep wrapping with a single
%wper level. The message reads as a sentence:load config: open /etc/app.toml: permission denied. - Use
%vdeliberately when you are crossing a boundary and want to hide internals — but never treat a wrapped error's text as an API. err != nilis the only correct nil check. Never compare error text with string equality.
Inspecting and classifying errors
func handle(err error) {
if err == nil {
return
}
if errors.Is(err, ErrNotFound) {
fmt.Println("missing resource") // walks the wrap chain
return
}
var ve *ValidationError // note: pointer to the custom type
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field)
return
}
fmt.Println("unexpected:", err)
}
// errors.Join keeps several failures together
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, &ValidationError{Field: "name", Msg: "required"})
}
if u.Age < 0 {
errs = append(errs, &ValidationError{Field: "age", Msg: "must not be negative"})
}
return errors.Join(errs...) // nil when errs is empty
}- Handle the error where you can do something about it: retry, substitute a default, or return a status code.
- Otherwise wrap it with context and return it. The call stack tells you where it came from; the message tells you what was attempted.
- At the top of a request, log it once with structure —
slog.Error("request failed", "err", err, "path", r.URL.Path). errors.Asneeds a pointer to the error type you are looking for, because it assigns to that target.
defer, panic and recover
func readFirst(path string) (line string, err error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close() // runs when the function returns, in LIFO order
defer func() {
// recover only makes sense in a deferred function
if r := recover(); r != nil {
err = fmt.Errorf("read %s: %v", path, r)
}
}()
buf := make([]byte, 128)
n, err := f.Read(buf)
if err != nil {
return "", err
}
return string(buf[:n]), nil
}- Deferred calls run last-in-first-out when the function returns, including on panic. That ordering is what makes nested locks and files correct without a cleanup ladder.
- Arguments to a deferred call are evaluated immediately, so
defer log.Println(err)prints the old value. Capture with a closure when you want the final value. - A deferred call inside a loop does not run until the enclosing function returns, so a loop that opens files needs its own function or an explicit close.
- Do not defer inside a goroutine that outlives the function you wrote it in; the defer belongs to the goroutine's own function.
⚠️
A panic means the program reached a state it cannot reason about; a
recover that swallows it and continues running corrupted state is worse than crashing. Recover is legitimate at exactly two places: turning a panic into an error at an RPC or HTTP handler boundary, and restarting a worker goroutine that must not take the process down.FAQ
When should I create a sentinel error instead of a custom type?
Use a sentinel when callers only need to ask "is this failure that kind?" —
errors.Is(err, ErrNotFound). Use a custom type when the caller needs data from the error, such as which field failed validation.Why is my deferred function not seeing the new value of a variable?
Arguments to
defer are evaluated at the moment the defer statement executes. Write defer func() { use(x) }() to read x at return time instead.Related
Structs, methods and interfaces Context, cancellation and concurrency patterns
Last refreshed 2026-09-18.