Error Handling in Go: errors.Is, errors.As, and Error Wrapping

1. Overview

In Go, errors are ordinary values. Functions return errors to their callers, and callers decide how to handle them. When an error passes through multiple layers like repository, service, and handler, you often want to add context while still recognizing the underlying error.

This article shows how to wrap errors with fmt.Errorf and %w, check errors with errors.Is, extract custom errors with errors.As, and combine multiple errors with errors.Join. At the end, you’ll find testing examples and common pitfalls. All examples use only the standard library and work with Go 1.20 and later. Only errors.AsType requires Go 1.26.

2. Sentinel Errors and the Limits of == Comparison

In Go, error is a very small interface:

type error interface {
	Error() string
}

The simplest way to define a “named” error is to create a sentinel error—a package-level variable that callers can compare directly:

package main

import (
	"errors"
	"fmt"
)

var ErrOrderNotFound = errors.New("order not found")

func findOrder(id int) error {
	if id != 42 {
		return ErrOrderNotFound
	}
	return nil
}

func main() {
	err := findOrder(7)
	if err == ErrOrderNotFound {
		fmt.Println("Order not found")
	}
}
Order not found

errors.New creates a new pointer. So err == ErrOrderNotFound is true only when err is that exact variable. This works fine until you start adding context to errors.

3. Wrapping Errors with fmt.Errorf and %w

3.1. How %w Differs from %v

Suppose the service layer wants to add the order ID to the error message:

func loadOrder(id int) error {
	if err := findOrder(id); err != nil {
		return fmt.Errorf("loadOrder id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := loadOrder(7)
	fmt.Println(err)
	fmt.Println(err == ErrOrderNotFound)
	fmt.Println(errors.Is(err, ErrOrderNotFound))
	fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true

Explanation for each line:

  • fmt.Errorf(... %w ...) creates a new error. This error retains a reference to the original err through an Unwrap() error method.
  • err == ErrOrderNotFound returns false because err is now a different value—a wrapped error.
  • errors.Is traverses the wrap chain and finds ErrOrderNotFound inside.
  • errors.Unwrap unwraps exactly one layer and returns the underlying error.

If you change %w to %v, the printed message stays the same. But errors.Is would return false because %v only copies the text string, not the underlying error.

3.2. Multi-Layer Wrap Chains

Each layer can add another wrap. errors.Is still finds the root error no matter how deep it is:

func handleGetOrder(id int) error {
	if err := loadOrder(id); err != nil {
		return fmt.Errorf("GET /orders/%d: %w", id, err)
	}
	return nil
}

// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true

The error message now reads like a condensed stack trace: the outermost layer is on the left, the root cause is on the right.

4. errors.Is: Checking “Is This Error X?”

errors.Is(err, target) returns true if any error in the chain satisfies one of two conditions:

  1. Equals target under ==, or
  2. Has an Is(error) bool method that returns true for target.

The second condition lets a custom error type declare itself as “equivalent” to a sentinel. For example, you have a common ErrNotFound error and a more detailed error type for customers:

var ErrNotFound = errors.New("not found")

type CustomerNotFoundError struct {
	CustomerID string
}

func (e CustomerNotFoundError) Error() string {
	return "customer " + e.CustomerID + " not found"
}

func (e CustomerNotFoundError) Is(target error) bool {
	return target == ErrNotFound
}

func main() {
	err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
	fmt.Println(errors.Is(err, ErrNotFound))
}
true

An HTTP handler only needs to check errors.Is(err, ErrNotFound) to return a 404. It doesn’t need to know whether the error came from an order, customer, or product.

Note: Don’t call Unwrap inside your Is method, because errors.Is already traverses the chain. The Is method should only answer for the current error type.

5. errors.As: Extracting Errors of a Specific Type

Sentinels only tell you what the error is. When you need additional data like error codes or IDs, use a custom error type and extract it with errors.As.

5.1. Custom Error Types with Unwrap

var ErrInsufficientFunds = errors.New("insufficient funds")

type PaymentError struct {
	OrderID int
	Code    string
	Err     error
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}

func (e *PaymentError) Unwrap() error { return e.Err }

type PaymentService struct{ balance int }

func (s *PaymentService) Charge(orderID, amount int) error {
	if amount > s.balance {
		return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
	}
	s.balance -= amount
	return nil
}

func checkout(s *PaymentService, orderID, amount int) error {
	if err := s.Charge(orderID, amount); err != nil {
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

The caller handles it like this:

func main() {
	svc := &PaymentService{balance: 100}
	err := checkout(svc, 42, 500)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("code:", pe.Code, "order:", pe.OrderID)
	}
	fmt.Println(errors.Is(err, ErrInsufficientFunds))
	fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds

Key points to understand:

  • errors.As traverses the wrap chain. When it finds the first error that can be assigned to type *PaymentError, it assigns it to pe and returns true.
  • Because PaymentError has Unwrap(), errors.Is(err, ErrInsufficientFunds) can still reach into the Err field.
  • The second argument must be a pointer to the target variable. Here it’s &pe, with type **PaymentError.

5.2. The Pointer Receiver Trap

The Error() method is declared on *PaymentError, so only *PaymentError implements error. Writing it like this will fail:

var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error

go vet catches this error at build time. Run go vet ./... in your CI. Easy rule: the type of the target variable must exactly match the type you returned. If you return &PaymentError{...}, the target must be *PaymentError.

5.3. errors.AsType (Go 1.26+)

From Go 1.26, the standard library has a generic version that’s more concise. You don’t need to declare a variable first:

if pe, ok := errors.AsType[*PaymentError](err); ok {
	fmt.Println("code:", pe.Code)
}

This approach is type-safe at compile time, so it can’t panic like in section 5.2. If your project is on Go 1.26, you should prefer this.

6. Combining Multiple Errors with errors.Join

From Go 1.20, an error can wrap multiple errors. A typical case is data validation, when you want to report all errors at once instead of stopping at the first one:

var (
	ErrEmptyName    = errors.New("name is empty")
	ErrInvalidEmail = errors.New("email is invalid")
)

type Customer struct {
	Name  string
	Email string
}

func validateCustomer(c Customer) error {
	var errs []error
	if c.Name == "" {
		errs = append(errs, ErrEmptyName)
	}
	if !strings.Contains(c.Email, "@") {
		errs = append(errs, ErrInvalidEmail)
	}
	return errors.Join(errs...)
}

func main() {
	err := validateCustomer(Customer{Name: "", Email: "abc"})
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
	fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true

Remember to add "strings" to your imports. The output shows three things:

  • errors.Join concatenates messages with newlines.
  • errors.Is and errors.As can check each individual error.
  • errors.Join returns nil if all arguments are nil or the list is empty. So you don’t need an extra if len(errs) == 0 check.

fmt.Errorf also accepts multiple %w in the same format string, like fmt.Errorf("sync order: %w; %w", errDB, errCache). The resulting error wraps both.

7. Writing Tests for Error Behavior

When testing, you should check the identity of errors using errors.Is or errors.As. Don’t compare error message strings. Here’s a table-driven test for checkout in a file checkout_test.go:

package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	tests := []struct {
		name     string
		balance  int
		amount   int
		wantErr  error
		wantCode string
	}{
		{name: "sufficient funds", balance: 1000, amount: 500},
		{name: "insufficient funds", balance: 100, amount: 500,
			wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)

			if tc.wantErr == nil {
				if err != nil {
					t.Fatalf("unexpected error, got: %v", err)
				}
				return
			}
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
			}
			var pe *PaymentError
			if !errors.As(err, &pe) {
				t.Fatalf("error does not contain *PaymentError: %v", err)
			}
			if pe.Code != tc.wantCode {
				t.Errorf("code = %q, want %q", pe.Code, tc.wantCode)
			}
		})
	}
}

Run the test:

go test -v -run TestCheckout ./...
=== RUN   TestCheckout
=== RUN   TestCheckout/sufficient_funds
=== RUN   TestCheckout/insufficient_funds
--- PASS: TestCheckout (0.00s)
    --- PASS: TestCheckout/sufficient_funds (0.00s)
    --- PASS: TestCheckout/insufficient_funds (0.00s)
PASS

This test still passes if someone later changes the error message or adds another wrap layer. That’s why you should check identity, not message strings.

8. Common Pitfalls and When NOT to Wrap

8.1. Comparing Error Message Strings

if strings.Contains(err.Error(), "not found") { ... } // fragile

One message change or another error that happens to contain “not found” and your logic breaks. Use sentinels with errors.Is instead.

8.2. Typed Nil

func (s *PaymentService) Refund(orderID int) error {
	var pe *PaymentError // nil
	// ... no error ...
	return pe // NOT nil!
}

The error interface now holds a *PaymentError type with a nil value. Because the interface has a type, err != nil still returns true. When there’s no error, always explicitly write return nil.

8.3. Wrapping Exposes Internal Details

When you wrap sql.ErrNoRows with %w, your package implicitly commits to letting callers check errors.Is(err, sql.ErrNoRows). That error becomes part of your public API. If you later switch to MongoDB, your caller’s code breaks.

At package boundaries, you have two better options:

if errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // translate to domain error
}
return fmt.Errorf("find order %d: %v", id, err) // keep message only, hide underlying error

Note: Using %w is an API decision, not just a formatting choice. Only wrap when you truly want callers to be able to check the underlying error.

8.4. Logging and Returning at the Same Time

if err != nil {
	log.Printf("charge failed: %v", err)
	return fmt.Errorf("charge: %w", err)
}

Do this at every layer and one error appears multiple times in the logs. The general rule is handle (log) or return, not both. Logging should be the responsibility of the outermost layer, like an HTTP handler or main.

8.5. Redundant Context or Missing Information

Messages like failed to load order: failed to query: failed to ... are long but add nothing. Write the operation name and key data like loadOrder id=7: .... Drop “failed” because if it’s an error, it obviously failed.

8.6. When NOT to Use errors.As

If you only need to know what type of error it is, errors.Is with a sentinel is enough and simpler. Only create custom error types when callers actually need the accompanying data, like error codes, invalid fields, or retry delays.

9. Conclusion

You’ve now covered Go’s error-handling toolkit:

  • fmt.Errorf with %w adds context while preserving the underlying error. %v only keeps the text.
  • errors.Is checks if an error matches a sentinel across the wrap chain. An error type can define an Is method to declare its own equivalence.
  • errors.As (or errors.AsType from Go 1.26) extracts an error of a specific type with its data. The target variable’s type must exactly match the returned type.
  • errors.Join and multiple %w allow one error to wrap multiple errors.

Finally, think of wrapping as part of your API design. Wrap intentionally, translate infrastructure errors into domain errors at package boundaries, and write tests that check error identity, not message strings.

golangerror handlingerrorsbest practices