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 originalerrthrough anUnwrap() errormethod.err == ErrOrderNotFoundreturnsfalsebecauseerris now a different value—a wrapped error.errors.Istraverses the wrap chain and findsErrOrderNotFoundinside.errors.Unwrapunwraps 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:
- Equals
targetunder==, or - Has an
Is(error) boolmethod that returnstruefortarget.
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
Unwrapinside yourIsmethod, becauseerrors.Isalready traverses the chain. TheIsmethod 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.Astraverses the wrap chain. When it finds the first error that can be assigned to type*PaymentError, it assigns it topeand returnstrue.- Because
PaymentErrorhasUnwrap(),errors.Is(err, ErrInsufficientFunds)can still reach into theErrfield. - 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.Joinconcatenates messages with newlines.errors.Isanderrors.Ascan check each individual error.errors.Joinreturnsnilif all arguments arenilor the list is empty. So you don’t need an extraif len(errs) == 0check.
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
%wis 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.Errorfwith%wadds context while preserving the underlying error.%vonly keeps the text.errors.Ischecks if an error matches a sentinel across the wrap chain. An error type can define anIsmethod to declare its own equivalence.errors.As(orerrors.AsTypefrom 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.Joinand multiple%wallow 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.