Skip to content
gohacks.dev
Basics

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.

7 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

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)
AsksIs this value in the chain?Is there an error of this type?
TargetAn error value, such as io.EOFA pointer to a variable of an error type or interface
Compares with==, or the error's own Is methodAssignability of the dynamic type
On a matchReturns trueStores the error in your variable, returns true
Typical useSentinelsTypes that carry data
Reads fields afterwardsNoYes

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:

main.goGo
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))
}
Text
false true
false
false true

The 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:

main.goGo
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)
	}
}
Text
op: open
path: /missing/data.json
cause: no such file or directory

pe 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:

main.goGo
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))
}
Text
timeout: true
true

net.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:

  1. It must be a non-nil pointer. errors.As(err, nil) panics with errors: target cannot be nil.
  2. 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 whose Error method has a pointer receiver, panics with errors: *target must be interface or implement error.
  3. The variable's type must match what is in the chain. Looking for *MyError finds *MyError and nothing else, so a value MyError in 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:

main.goGo
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))
}
Text
502
true true

Both 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 As with 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:

main.goGo
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")))
}
Text
200
422
404
500

A 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.