Errors: Wrapping, Is & As

Go · Lesson 13 of 32 6 min read Updated

Go has no exceptions and no try/catch. Instead, a function that can fail returns an error value, and the caller decides what to do with it. This makes failure paths visible in the code: you can see exactly where each error is checked.

In this lesson you will create errors, add context by wrapping them, inspect wrapped errors with errors.Is, errors.As and the newer errors.AsType, define your own error types, combine several errors, and learn the small role panic and recover play.

The error type

error is a built-in interface (a type defined by its methods, covered in Interfaces). Any type with a method Error() string is an error. The two simplest ways to make one are errors.New for a fixed message and fmt.Errorf for a formatted one.

By convention, error messages start with a lower-case letter and have no full stop at the end, because they are often joined with other messages. The usual check looks like this:

Go
qty, err := strconv.Atoi(input)
if err != nil {
	return fmt.Errorf("read quantity: %w", err)
}
// use qty here

Handle the error first and keep the success path unindented, as you saw with guard clauses in if & switch.

Sentinel errors and wrapping with %w

A sentinel error is an error stored in a package-level variable so callers can recognise it. Its name starts with Err. As an error travels up through function calls, each level can wrap it with more context using the %w verb in fmt.Errorf. The original error stays inside, like a letter in an envelope.

Go
package main

import (
	"errors"
	"fmt"
)

var ErrOutOfStock = errors.New("out of stock")

func reserve(item string, available int) error {
	if available == 0 {
		return ErrOutOfStock
	}
	return nil
}

func placeOrder(id int, item string, available int) error {
	if err := reserve(item, available); err != nil {
		return fmt.Errorf("order %d: reserve %s: %w", id, item, err)
	}
	return nil
}

func main() {
	err := placeOrder(42, "cricket bat", 0)
	fmt.Println(err)
	fmt.Println(err == ErrOutOfStock)
	fmt.Println(errors.Is(err, ErrOutOfStock))
	fmt.Println(errors.Unwrap(err) == ErrOutOfStock)
}
Output
order 42: reserve cricket bat: out of stock
false
true
true

The message reads like a trail: which order, which step, what went wrong. Plain == fails because err is now the wrapper, not the sentinel. errors.Is unwraps layer by layer and finds the match, so always use errors.Is to check for a specific error. errors.Unwrap removes exactly one layer.

Custom error types with As and AsType

Sometimes the caller needs data from an error, not just its identity: which amount failed, which field was wrong. Define your own type with an Error method. The (e *PaymentError) part before the method name is a receiver, which attaches the method to the type; Structs & Methods explains it fully.

Go
package main

import (
	"errors"
	"fmt"
)

type PaymentError struct {
	Amount int
	Reason string
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment of ₹%d failed: %s", e.Amount, e.Reason)
}

func checkout(amount int) error {
	if amount > 5000 {
		err := &PaymentError{Amount: amount, Reason: "daily limit exceeded"}
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

func main() {
	err := checkout(7500)
	fmt.Println(err)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("Reason:", pe.Reason)
	}

	if pe, ok := errors.AsType[*PaymentError](err); ok {
		fmt.Println("Amount:", pe.Amount)
	}
}
Output
checkout: payment of ₹7500 failed: daily limit exceeded
Reason: daily limit exceeded
Amount: 7500

errors.As searches the chain for an error of the target's type and, if found, stores it in the variable you pass by address (&pe). errors.AsType, added in Go 1.26, does the same job with generics: you name the type in square brackets and get the value and a bool back. It needs no separate variable and cannot be called with the wrong kind of argument, so prefer it in new code.

Joining several errors

Form validation often finds more than one problem. errors.Join (Go 1.20) combines errors into one. It skips nil values and returns nil if all of them are nil. The combined message puts each error on its own line, and errors.Is and errors.As still find every one inside.

Go
package main

import (
	"errors"
	"fmt"
)

var ErrNameMissing = errors.New("name is required")

func validate(name string, age int) error {
	var nameErr, ageErr error
	if name == "" {
		nameErr = ErrNameMissing
	}
	if age < 18 {
		ageErr = fmt.Errorf("age %d is below 18", age)
	}
	return errors.Join(nameErr, ageErr)
}

func main() {
	err := validate("", 16)
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrNameMissing))
	fmt.Println(validate("Meera", 22) == nil)
}
Output
name is required
age 16 is below 18
true
true

panic and recover

A panic stops the normal flow of a program. The runtime panics on bugs such as an index out of range or a division by zero, and you can call panic("message") yourself for situations that should be impossible. An unhandled panic prints the message and a stack trace, and the program exits with status 2.

recover, called inside a deferred function, stops a panic and returns its value. Servers use it so that one bad request cannot crash the whole program:

Go
package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(1, 0))
}
Output
5 <nil>
0 recovered: runtime error: integer divide by zero

The deferred function runs while the panic unwinds, sets the named result err, and the function returns normally. Do not use panic for ordinary failures like a missing file or bad user input; return an error instead. Panics are for programmer mistakes.

Common mistakes

  • Comparing wrapped errors with ==. Use errors.Is(err, ErrX).
  • Wrapping with %v instead of %w. The cause is lost to errors.Is and errors.As.
  • Logging and returning the same error. It gets printed several times. Either handle it (log it) or return it with context, not both.
  • Capitalised messages with full stops. Write "open config: no such file", not "Open config: No such file.".

Key points

  • Functions return error as their last result; nil means success.
  • Add context with fmt.Errorf("doing x: %w", err); %w keeps the original error inside.
  • errors.Is finds a specific error value anywhere in the chain.
  • errors.As and the generic errors.AsType (Go 1.26) find an error of a specific type and give you its fields.
  • errors.Join combines several errors; panic/recover are for bugs, not normal failures.

Exercise

Create a sentinel error ErrInsufficientBalance. Write withdraw(balance, amount int) (int, error) that returns the sentinel when amount is larger than balance, and the new balance otherwise. Write atmWithdraw(card string, balance, amount int) error that wraps any error with the card number. In main, try withdrawing ₹3,000 from a balance of ₹2,500 for card XXXX-1234 and print the error and whether errors.Is matches the sentinel.

Show solution
Go
package main

import (
	"errors"
	"fmt"
)

var ErrInsufficientBalance = errors.New("insufficient balance")

func withdraw(balance, amount int) (int, error) {
	if amount > balance {
		return balance, ErrInsufficientBalance
	}
	return balance - amount, nil
}

func atmWithdraw(card string, balance, amount int) error {
	if _, err := withdraw(balance, amount); err != nil {
		return fmt.Errorf("card %s: withdraw ₹%d: %w", card, amount, err)
	}
	return nil
}

func main() {
	err := atmWithdraw("XXXX-1234", 2500, 3000)
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrInsufficientBalance))
}
Output
card XXXX-1234: withdraw ₹3000: insufficient balance
true

The %w verb keeps the sentinel inside the wrapper, so errors.Is still recognises it after the card number and amount were added.

Quick Quiz

1. Which verb in fmt.Errorf keeps the original error available to errors.Is?

2. err wraps ErrNotFound. What does err == ErrNotFound return?

3. What does errors.AsType[*PaymentError](err) return?

4. When should Go code use panic?

Learn Go with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call