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:

GO
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
}
Output
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:

GO
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))
}
Output
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:

GO
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)
	}
}
Output
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:

GO
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)
	}
}
Output
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:

GO
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)
	}
}
Output
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:

GO
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)
}
go vet
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:

GO
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))
}
Output
<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.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
loading user 404: not found
respond with HTTP 404
Next: panic and recover.