errors Is and errors As in Go and When to Use Each One
Go errors.Is asks whether a specific error is in the chain, and errors.As pulls out one of a given type. See how each behaves, with a rule for choosing.

Once an error is wrapped, the usual ways of looking at it stop working. err == io.EOF is false for fmt.Errorf("read header: %w", io.EOF), and err.(*PathError) fails on the same wrapper. Go 1.13 added two functions to the errors package to ask questions of a whole chain instead of its outermost layer: errors.Is and errors.As. They look alike and answer different questions, and choosing between them is simple once you see what each returns.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article compares them side by side, covers the rules for the target of As, and ends with a decision test. It belongs to the error handling guide, and the two sections that precede it, wrapping with %w and custom error types, explain how chains are built and what goes in them. Everything ran on Go 1.25.6.
The difference in one table
errors.Is(err, target) | errors.As(err, &target) | |
|---|---|---|
| Asks | Is this value in the chain? | Is there an error of this type? |
| Target | An error value, such as io.EOF | A pointer to a variable of an error type or interface |
| Compares with | ==, or the error's own Is method | Assignability of the dynamic type |
| On a match | Returns true | Stores the error in your variable, returns true |
| Typical use | Sentinels | Types that carry data |
| Reads fields afterwards | No | Yes |
The short version: Is for a name, As for a shape.
errors.Is: a particular error
A sentinel is a package-level variable, such as io.EOF, sql.ErrNoRows or your own ErrNotFound. Is returns true if any error in the chain equals it:
package main
import (
"errors"
"fmt"
"io"
)
var ErrNotFound = errors.New("not found")
func main() {
err := fmt.Errorf("get user 7: %w", ErrNotFound)
fmt.Println(err == ErrNotFound, errors.Is(err, ErrNotFound))
fmt.Println(errors.Is(err, io.EOF))
fmt.Println(errors.Is(nil, ErrNotFound), errors.Is(nil, nil))
}false true
false
false trueThe first line is the whole reason Is exists: the wrapped error is not equal to the sentinel, yet Is finds it. The last line shows the edge cases. A nil error contains nothing, so Is(nil, ErrNotFound) is false, and Is(nil, nil) is true because it reduces to nil == nil. You rarely need either, but they explain why errors.Is(err, nil) is not a way to test for success; write err == nil.
By default, matching is ==, which for an errors.New value means identity: two separate calls to errors.New("not found") are different errors even though they print the same. An error type may define its own Is(target error) bool method to loosen that, and errors.Is calls it at each step of the chain (custom error types shows an example).
errors.As: a particular shape
As is for the times you need the error's contents. It takes a pointer to a variable of the type you are looking for, and fills it in on a match:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
_, err := os.ReadFile("/missing/data.json")
err = fmt.Errorf("load data: %w", err)
var pe *fs.PathError
if errors.As(err, &pe) {
fmt.Println("op:", pe.Op)
fmt.Println("path:", pe.Path)
fmt.Println("cause:", pe.Err)
}
}op: open
path: /missing/data.json
cause: no such file or directorype starts as a nil *fs.PathError, and As sets it to the one it finds. From there you read its fields as with any value. The code is not parsing the message, and the message can change without breaking it.
The target can also be an interface type, and As then finds the first error in the chain that implements it. The standard library's net package relies on this for timeouts:
package main
import (
"errors"
"fmt"
"net"
"os"
"time"
)
func main() {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
client.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
_, err := client.Read(make([]byte, 1))
var ne net.Error
if errors.As(err, &ne) {
fmt.Println("timeout:", ne.Timeout())
}
fmt.Println(errors.Is(err, os.ErrDeadlineExceeded))
}timeout: true
truenet.Error is an interface with Timeout() and Temporary() methods. The program reads from one end of an in-memory pipe that nobody writes to, with a 20 ms deadline, then asks for anything in the chain that behaves like a net.Error and calls its Timeout method. It asks Is separately whether the cause was the standard deadline sentinel. The two questions complement each other, and on a real network connection the same two lines tell a timeout from a refused connection (timeouts in net code covers those cases in depth).
Rules for the As target
The second argument of errors.As is easy to get wrong, because it is a pointer to a pointer when the error type is a pointer, which is the common case. Three rules prevent every failure:
- It must be a non-nil pointer.
errors.As(err, nil)panics witherrors: target cannot be nil. - The thing it points to must be an error type or an interface. A pointer to a type that does not implement
error, such as a struct whoseErrormethod has a pointer receiver, panics witherrors: *target must be interface or implement error. - The variable's type must match what is in the chain. Looking for
*MyErrorfinds*MyErrorand nothing else, so a valueMyErrorin the chain does not match.
go vet has an analyzer, errorsas, that finds the first two at build time, so the panics rarely survive a CI run. The third gives a plain false.
The outermost match wins
As walks the chain from the outside in and stops at the first match. When two errors of the same type are nested, you get the outer one:
package main
import (
"errors"
"fmt"
)
type StatusError struct{ Code int }
func (e *StatusError) Error() string { return fmt.Sprint("status ", e.Code) }
func main() {
inner := &StatusError{Code: 404}
outer := &StatusError{Code: 502}
err := fmt.Errorf("gateway: %w", fmt.Errorf("proxy: %w (while handling %w)", outer, inner))
var se *StatusError
errors.As(err, &se)
fmt.Println(se.Code)
fmt.Println(errors.Is(err, inner), errors.Is(err, outer))
}502
true trueBoth Is calls find their targets, because Is checks every branch, but As returns the first match it reaches in a depth-first walk, which is the 502. If a type can appear twice in a chain, the code that handles it has to be aware that it sees only one of them. If you need all of them, walk the chain yourself with errors.Unwrap, or avoid designing chains that repeat a type.
Choosing, in practice
Run through these questions in order and stop at the first yes:
- Does the error carry data you need? A status code, a path, a field name, a retry delay. Use
As, with the concrete type. - Do you need a behavior and not a type? Timeout, temporary, "is it retryable". Use
Aswith a small interface. - Is it a named condition with no data? End of file, no rows, permission denied, not found. Use
Is, with the sentinel. - None of the above. Don't branch on it. Log it or return it.
Combined, they make a clean error switch at a boundary, such as the place in an HTTP handler that picks a status:
package main
import (
"errors"
"fmt"
"io/fs"
)
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return "invalid " + e.Field }
func status(err error) int {
var ve *ValidationError
switch {
case err == nil:
return 200
case errors.As(err, &ve):
return 422
case errors.Is(err, fs.ErrNotExist):
return 404
default:
return 500
}
}
func main() {
fmt.Println(status(nil))
fmt.Println(status(fmt.Errorf("save: %w", &ValidationError{"email"})))
fmt.Println(status(fmt.Errorf("load: %w", fs.ErrNotExist)))
fmt.Println(status(errors.New("disk on fire")))
}200
422
404
500A switch with no tag reads like an if-else chain and keeps the mapping in one function, which is where it belongs (mapping errors to HTTP status codes takes this further). Note the order: err == nil first, because errors.Is and errors.As on nil are both false and the default branch would call a success a 500.
Where the choice comes from
One habit pays off more than any rule above: write the check at the point where you need a decision, not at the point where the error appears. A function deep in a call stack should return the error with context, and the code that picks a status code, retries or shows a message is the one that calls Is or As. Checking early and then re-wrapping makes the chain harder to read and spreads the knowledge of error types across layers that have no use for it.
If you find yourself reaching for As with a type your own package defines, ask whether callers should have to know that type exists. If they should not, an interface or a sentinel gives the same decision with less coupling, and error design lays out the trade-off. For the reference text, see the errors package and the Go blog's Working with Errors in Go 1.13.


