errors.Is and errors.As in Go
How to check which error happened with errors.Is, how to pull a typed error out of a chain with errors.As, and why == on errors stops working.
Getting an error back is only half the job. Often your code needs to react differently depending on which error it was. A missing file might be fine because you can fall back to defaults, while a permission error should stop the program. errors.Is and errors.As are the two tools Go gives you to ask those questions safely.
Why == Stops Working
Before wrapping existed, Go code compared errors directly:
if err == io.EOF { ... }
This still works when nobody has wrapped the error. The moment one layer adds context with %w, the error you receive is a new value that only contains the original, so == returns false.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func find() error {
return fmt.Errorf("find user 7: %w", ErrNotFound)
}
func main() {
err := find()
fmt.Println("== :", err == ErrNotFound)
fmt.Println("errors.Is :", errors.Is(err, ErrNotFound))
}
Output:
== : false
errors.Is : true
errors.Is opens each envelope in the chain and compares every layer with the target. It finds ErrNotFound one layer down.
Never compare error strings:
strings.Contains(err.Error(), "not found") breaks when the wording changes, when a translation is added, or when an unrelated error happens to contain the same words. Use errors.Is for known error values and errors.As for error types.
errors.Is Asks "Is This Error in the Chain?"
Use errors.Is(err, target) when the target is a specific error value, usually a package-level variable. Here is a realistic case: load a config file, but use defaults if it does not exist.
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func loadConfig(path string) (string, error) {
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return "defaults", nil
}
if err != nil {
return "", fmt.Errorf("load config: %w", err)
}
return string(data), nil
}
func main() {
cfg, err := loadConfig("no-such-file.conf")
fmt.Println(cfg, err)
}
The file does not exist, so errors.Is matches and the program prints defaults <nil>. Any other failure, such as a permission problem, is passed on with context.
Notice the order of the two checks. The specific check comes first, then the general err != nil. If you swap them, the general check catches everything and the fallback never runs.
Error values from the standard library worth knowing
| Check | Meaning |
|---|---|
errors.Is(err, fs.ErrNotExist) | file or directory does not exist (also os.ErrNotExist) |
errors.Is(err, fs.ErrPermission) | permission denied |
errors.Is(err, io.EOF) | a reader has no more data, a normal end |
errors.Is(err, sql.ErrNoRows) | a database query matched no rows |
errors.Is(err, context.DeadlineExceeded) | an operation ran out of time |
errors.Is(err, context.Canceled) | the caller gave up |
errors.Is(err, http.ErrServerClosed) | the HTTP server was shut down on purpose |
errors.As Asks "Give Me the Typed Error"
Sometimes you need more than a yes or no. You want the details stored inside the error, such as the file path or an HTTP status code. Those details live in fields of an error type. errors.As searches the chain for an error of a given type and, if it finds one, copies it into your variable.
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
_, err := os.Open("/missing/report.csv")
err = fmt.Errorf("export report: %w", err)
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("operation:", pathErr.Op)
fmt.Println("path: ", pathErr.Path)
fmt.Println("cause: ", pathErr.Err)
}
}
Output:
operation: open
path: /missing/report.csv
cause: no such file or directory
Three steps, always in this order: declare a variable of the error type you want, pass a pointer to it, and use it only inside the if where errors.As returned true.
Using errors.As with your own types
package main
import (
"errors"
"fmt"
)
type StatusError struct {
Code int
URL string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("GET %s: status %d", e.URL, e.Code)
}
func fetch(url string) error {
return &StatusError{Code: 429, URL: url}
}
func main() {
err := fmt.Errorf("sync prices: %w", fetch("https://api.example.com/prices"))
var se *StatusError
if errors.As(err, &se) && se.Code == 429 {
fmt.Println("rate limited, retry later:", se.URL)
}
}
This prints rate limited, retry later: https://api.example.com/prices. The caller can decide to retry because it could read the status code, not just the text.
Pointer or value: match what the function returns:
fetch returns &StatusError{...}, a pointer, so the target must be var se *StatusError. If you declare var se StatusError instead, errors.As finds no match and returns false with no complaint. Look at how the error is created and use the same form.
Common errors.As Mistakes and Their Messages
Passing the variable itself instead of a pointer to it fails at run time. go vet catches it first with a message like:
second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
If you run it anyway, the program panics with:
panic: errors: target must be a non-nil pointer
The fix is always the same: errors.As(err, &target), with the &.
Choosing Between errors.Is and errors.As
- You want to know whether a known error happened:
errors.Is. - You want to read data from the error:
errors.As. - You only want to know if anything failed at all: plain
err != nil.
Is it a specific, named error value? -> errors.Is(err, ErrX)
Do I need fields like Path, Code, Op? -> errors.As(err, &target)
Do I just need to know it failed? -> err != nil
errors.AsType in newer Go:
Go 1.26 adds errors.AsType, a generic version that returns the typed error and a bool, for example pe, ok := errors.AsType[*fs.PathError](err). It avoids the separate variable and cannot be called with a wrong pointer. On Go versions before 1.26, use errors.As.
You are checking errors the robust way:
If your code never uses == against wrapped errors and never matches on err.Error() text, adding more context with %w anywhere in the program will not break any of your checks.
Summary
== only matches an error that was never wrapped, so prefer errors.Is(err, target), which searches the whole chain for a specific value. Use errors.As(err, &target) to find an error of a certain type and read its fields, always passing a pointer and matching the pointer or value form the error was created with. Put specific checks before the general err != nil, and never compare error message text.