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
}
KindDeclared asChecked with
Sentinelvar ErrNotFound = errors.New(...)errors.Is(err, ErrNotFound)
Custom typetype ValidationError struct{...} with an Error() methoderrors.As(err, &target)
Wrappedfmt.Errorf("...: %w", err)errors.Is / errors.As through the chain
Opaquefmt.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 %w per level. The message reads as a sentence: load config: open /etc/app.toml: permission denied.
  • Use %v deliberately when you are crossing a boundary and want to hide internals — but never treat a wrapped error's text as an API.
  • err != nil is 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
}
  1. Handle the error where you can do something about it: retry, substitute a default, or return a status code.
  2. Otherwise wrap it with context and return it. The call stack tells you where it came from; the message tells you what was attempted.
  3. At the top of a request, log it once with structure — slog.Error("request failed", "err", err, "path", r.URL.Path).
  4. errors.As needs 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.

Structs, methods and interfaces Context, cancellation and concurrency patterns

Last refreshed 2026-09-18.