Wrapping and Inspecting Errors
An error from deep inside a program — "no such file or directory" — is much more useful when each layer adds what it was doing: "starting server: loading config: open app.toml: no such file or directory". Go lets you add that context by wrapping errors, while keeping the original inside so code can still ask "is this a not-found error?" This lesson covers %w, errors.Is, errors.As and errors.AsType, custom error types, and errors.Join.
Wrapping with %w
fmt.Errorf with the %w verb creates a new error whose message includes the old one, and which remembers the old one:
package main import ( "errors" "fmt" "os" ) func readConfig(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config: %w", err) } return data, nil } func startServer() error { if _, err := readConfig("app.toml"); err != nil { return fmt.Errorf("starting server: %w", err) } return nil } func main() { err := startServer() fmt.Println(err) fmt.Println(errors.Is(err, os.ErrNotExist)) // still recognisable fmt.Println(errors.Unwrap(err)) // one layer down }
starting server: reading config: open app.toml: no such file or directory true reading config: open app.toml: no such file or directory
Each layer adds a short description of what it was attempting, so the final message reads like a trail. Using %v instead of %w would produce the same text but break the chain: errors.Is would no longer find os.ErrNotExist.
errors.Is
errors.Is(err, target) walks down the chain of wrapped errors and reports whether any of them equals target. Use it to check for sentinel errors:
package main import ( "errors" "fmt" ) var ErrOutOfStock = errors.New("out of stock") func reserve(item string) error { return fmt.Errorf("reserving %s: %w", item, ErrOutOfStock) } func placeOrder() error { if err := reserve("lamp"); err != nil { return fmt.Errorf("order 1042: %w", err) } return nil } func main() { err := placeOrder() fmt.Println(err) fmt.Println(err == ErrOutOfStock, errors.Is(err, ErrOutOfStock)) }
order 1042: reserving lamp: out of stock false true
Custom error types
When callers need details — which field was invalid, what status code came back — define a struct type that implements error:
package main import ( "errors" "fmt" ) type HTTPError struct { Status int URL string } func (e *HTTPError) Error() string { return fmt.Sprintf("GET %s: status %d", e.URL, e.Status) } func fetch(url string) error { return &HTTPError{Status: 503, URL: url} } func refreshPrices() error { if err := fetch("https://api.example.com/prices"); err != nil { return fmt.Errorf("refreshing prices: %w", err) } return nil } func main() { err := refreshPrices() fmt.Println(err) if he, ok := errors.AsType[*HTTPError](err); ok { fmt.Println("status:", he.Status, "retry later:", he.Status >= 500) } }
refreshing prices: GET https://api.example.com/prices: status 503 status: 503 retry later: true
errors.AsType and errors.As
errors.AsType[T](err) searches the chain for the first error of type T and returns it with true, or the zero value and false. It was added in Go 1.26. Older code uses errors.As, which does the same thing through a pointer to a variable:
package main import ( "errors" "fmt" "io/fs" "os" ) func main() { _, err := os.Open("/no/such/file") err = fmt.Errorf("loading: %w", err) // Go 1.26 and later if pe, ok := errors.AsType[*fs.PathError](err); ok { fmt.Println("operation:", pe.Op, "path:", pe.Path) } // the older form var pe *fs.PathError if errors.As(err, &pe) { fmt.Println("underlying:", pe.Err) } }
operation: open path: /no/such/file underlying: no such file or directory
Use Is to ask "is it this particular error value?" and AsType (or As) to ask "is there an error of this type, and give me it".
Making your own type unwrappable
A custom error type that wraps another error should have an Unwrap() error method so Is and AsType can see through it:
package main import ( "errors" "fmt" "strconv" ) type ParseError struct { Line int Err error } func (e *ParseError) Error() string { return fmt.Sprintf("line %d: %v", e.Line, e.Err) } func (e *ParseError) Unwrap() error { return e.Err } func parseLines(lines []string) (int, error) { sum := 0 for i, l := range lines { n, err := strconv.Atoi(l) if err != nil { return 0, &ParseError{Line: i + 1, Err: err} } sum += n } return sum, nil } func main() { _, err := parseLines([]string{"10", "20", "3O"}) fmt.Println(err) fmt.Println(errors.Is(err, strconv.ErrSyntax)) if pe, ok := errors.AsType[*ParseError](err); ok { fmt.Println("fix line", pe.Line) } }
line 3: strconv.Atoi: parsing "3O": invalid syntax true fix line 3
A vet check for wrapping
Since Go 1.27, go vet warns when you wrap a pointer to a value type whose methods make it an error — usually a sign the wrong thing is being wrapped, which then makes errors.Is and type checks fail:
package main import "fmt" type TimeoutError struct{ Op string } func (e TimeoutError) Error() string { return e.Op + ": timed out" } func main() { e := TimeoutError{"dial"} err := fmt.Errorf("connecting: %w", &e) fmt.Println(err) }
main.go:11:33: %w wants operand of error type TimeoutError, not pointer type *TimeoutError (defeats errors.Is)
Combining errors with errors.Join
When several things can fail independently — validating a form, closing several files — errors.Join combines the errors into one. Its message has one line per error, and Is/AsType look at all of them:
package main import ( "errors" "fmt" "strings" ) var ErrRequired = errors.New("required") func validate(name, email string, age int) error { var errs []error if strings.TrimSpace(name) == "" { errs = append(errs, fmt.Errorf("name: %w", ErrRequired)) } if !strings.Contains(email, "@") { errs = append(errs, fmt.Errorf("email: %q is not valid", email)) } if age < 0 { errs = append(errs, fmt.Errorf("age: must not be negative")) } return errors.Join(errs...) // nil when errs is empty } func main() { fmt.Println(validate("Ana", "ana@example.com", 30)) err := validate("", "nope", -1) fmt.Println(err) fmt.Println("missing field:", errors.Is(err, ErrRequired)) }
<nil> name: required email: "nope" is not valid age: must not be negative missing field: true
fmt.Errorf also accepts several %w verbs in one call, which wraps all of those errors.
When not to wrap
Wrapping makes the inner error part of your function's contract: callers may start checking for it. If you'd rather keep that detail private — for example, you might switch from one database library to another — describe the failure with %v instead, or return your own sentinel error. And don't wrap just to repeat information already in the message.
Try it yourself
Add a third layer, handleRequest, that wraps the error from loadUser, and check that errors.Is still finds ErrNotFound.
loading user 404: not found respond with HTTP 404
panic and recover.