GAP·MAP

Go error handling

[ middle depth ]

Errors are ordinary values in Go

Go has no exceptions for expected failures. A function that can fail returns (T, error) with the error last, and the caller checks it against nil:

f, err := os.Open(name)
if err != nil {
    return err // hand it up, or add context first
}

error is a one-method interface: type error interface { Error() string }. Any type with an Error() string method is an error. errors.New("not found") returns a pointer to a small struct holding that text, so two separate New calls with the same string are different values. That is why a shared error is declared once as a package-level variable and reused.

Wrapping with %w versus annotating with %v

fmt.Errorf("load config: %w", err) returns a new error that reads as load config: <cause> and records the cause so callers can retrieve it. Those links form a chain you can walk back to the original. Swap %w for %v and the message is byte-for-byte identical, but the cause is no longer part of the chain.

Wrong "I used %v, so errors.Is still finds the cause." Only %w adds the operand to the chain. Right %v formats the same text while dropping the error from the chain, which is what you want when you are hiding a dependency's error type from your own callers.

errors.Is matches a value, errors.As extracts a type

Once an error is wrapped, == no longer finds the original:

var ErrNotFound = errors.New("not found")
err := fmt.Errorf("find user 42: %w", ErrNotFound)

err == ErrNotFound          // false: err is the wrapper, not the sentinel
errors.Is(err, ErrNotFound) // true:  Is walks the chain to the sentinel

errors.Is(err, target) reports whether any error in the chain equals a sentinel value, so it keeps working through wrapping. errors.As(err, &target) finds the first error in the chain of a concrete type and assigns it, so you can read its fields:

var pe *fs.PathError
if errors.As(err, &pe) { // note the pointer to the target
    log.Print(pe.Path)
}

Is is for a sentinel value, As is for a concrete type. Swapping the two is the single most common mistake on this topic.

panic and recover, and why recover must be deferred

panic unwinds the stack, running deferred functions on the way up, and crashes the program if nothing stops it. recover halts that unwinding, but only from inside a deferred function:

func safe() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    panic("boom")
}
// safe() prints: recovered: boom

Call recover() anywhere other than a deferred function and it returns nil and does nothing, so the panic keeps unwinding. Reserve panic for unrecoverable states and programmer bugs; expected failures return an error.

[ senior depth ]

When wrapping makes an error part of your API

Wrapping is an API decision, not a formatting choice. fmt.Errorf("...: %w", err) publishes the wrapped error: callers can now match it with errors.Is and errors.As, so you cannot swap or drop that underlying error later without risking their code. The rule from the Go 1.13 blog is worth memorizing: wrapping an error makes it part of your API. When you want context for a human but no such commitment, annotate with %v so the dependency's error type stays private:

return fmt.Errorf("query users: %w", dbErr) // leaks the driver's error type
return fmt.Errorf("query users: %v", dbErr) // same text, cause kept private

The printed message is identical either way; the difference is only in what other programs can extract.

The error tree: single %w, multiple %w, and errors.Unwrap limits

One %w produces a value with Unwrap() error. Since Go 1.20 you can pass more than one %w, and the result implements Unwrap() []error instead:

e1, e2 := errors.New("a"), errors.New("b")
err := fmt.Errorf("%w and %w", e1, e2) // Go 1.20+
errors.Is(err, e1) // true
errors.Is(err, e2) // true
errors.Unwrap(err) // returns nil

errors.Is and errors.As walk the whole tree, following both the single- and slice-form Unwrap depth-first. errors.Unwrap does not: it calls only the Unwrap() error form and returns nil for anything with Unwrap() []error, including the result of multiple %w and of errors.Join.

Wrong "errors.Unwrap peels the whole chain." It removes exactly one Unwrap() error layer per call and cannot descend a multi-error node at all. To reach those children, type-assert to interface{ Unwrap() []error }, or rely on errors.Is/errors.As.

errors.As pointer rules, panics, and AsType

errors.As(err, target) requires target to be a non-nil pointer to a type that implements error, or to an interface. Pass the error value itself, a nil pointer, or a pointer to a non-error type and it panics rather than returning false:

var pe *fs.PathError
errors.As(err, &pe) // ok:    target is **fs.PathError
errors.As(err, pe)  // panic: pe points at fs.PathError, not an error type

fs.PathError's Error method has a pointer receiver, so only *fs.PathError satisfies error; passing pe aims As at the value type and it panics. Go 1.26 adds a generic form, AsType[E error](err error) (E, bool), that returns the typed value directly:

if pe, ok := errors.AsType[*fs.PathError](err); ok {
    log.Print(pe.Path)
}

panic, recover, and the boundaries recover cannot cross

recover captures a panic only from a deferred function on the same goroutine. The common pattern converts a panic into an error through a named return:

func safeDiv(a, b int) (q int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    return a / b, nil
}
// safeDiv(1, 0) returns q=0 and sets err to the runtime's
// integer-divide-by-zero panic value, formatted into the message

Two limits catch people. The panicking function does not resume where it panicked; unwinding runs its deferred calls in LIFO order and its enclosing function returns, so a named return is how you surface a value. And a panic in one goroutine cannot be recovered from another: each goroutine unwinds its own stack, and an unrecovered panic anywhere crashes the whole program.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Go

was this useful?