Skip to content

Intermediate · Pointers, interfaces and errors · topic 19 of 46 · 40 min

Errors as values: wrap, Is and As

Explanation

In Go an error is a normal value returned from a function, not an exception. You check it with if err != nil right away. Add context with fmt.Errorf and %w (wrapping), then check for a specific error anywhere up the chain with errors.Is, or pull out a custom error type with errors.As.

Why errors as values?

Many languages throw exceptions that jump out of functions and are caught somewhere far away. It is easy to forget which calls can fail. Go makes failure visible: a function that can fail returns an error as its last result, and the caller decides what to do, right there. The code is longer, but the flow of control is always clear.

The error type

go
type error interface {
	Error() string
}
error is a built-in interface with one method. Anything with Error() string is an error.
go
f, err := os.Open("marks.csv")
if err != nil {
	return fmt.Errorf("open marks file: %w", err)
}
defer f.Close()
Check immediately, add context, return it. This pattern appears everywhere in Go.

Creating errors

go
import "errors"

var ErrNotFound = errors.New("not found")          // a sentinel error

func findUser(id int) (string, error) {
	if id != 1 {
		return "", ErrNotFound
	}
	return "Asha", nil
}

err := fmt.Errorf("invalid amount %d", -5)          // formatted, no wrapping
A sentinel error is a package-level error variable that callers can compare against: io.EOF, sql.ErrNoRows and os.ErrNotExist are famous ones.
Tip: Error strings start with a lowercase letter and have no full stop at the end, because they are often joined: "load config: open file: permission denied".

Wrapping with %w: adding context

A bare "not found" deep inside a big program is hard to debug. Each layer should add what it was doing. fmt.Errorf with the %w verb creates a new error whose message includes the old one AND keeps the old error inside, so it can still be found later. This is called wrapping.

go
func loadProfile(id int) error {
	_, err := findUser(id)
	if err != nil {
		return fmt.Errorf("load profile %d: %w", id, err)
	}
	return nil
}

err := loadProfile(7)
fmt.Println(err)                         // load profile 7: not found
fmt.Println(errors.Is(err, ErrNotFound)) // true
errors.Is looks through every wrapped layer for a match.
Careful: Comparing with == only checks the outer error: err == ErrNotFound is false here because err is the wrapper. Always use errors.Is for sentinel errors. Using %v instead of %w keeps the message but breaks the chain, so errors.Is would then return false.

Custom error types and errors.As

Sometimes a caller needs details: which field failed validation, which HTTP status the bank returned. Make a struct type with an Error method, return it (wrapped or not), and the caller extracts it with errors.As, which also looks through the chain.

go
type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return e.Field + ": " + e.Msg
}

func validate(age int) error {
	if age < 18 {
		return &ValidationError{Field: "age", Msg: "must be 18 or older"}
	}
	return nil
}

err := fmt.Errorf("signup: %w", validate(15))
var ve *ValidationError
if errors.As(err, &ve) {
	fmt.Println("bad field:", ve.Field) // bad field: age
}
Pass a pointer to a variable of the target type. If found, errors.As fills it and returns true.

Several errors at once

Go 1.20 added errors.Join(err1, err2, ...), which combines several errors into one (nil ones are dropped). errors.Is and errors.As check each of them. It is useful when validating a form with several bad fields.

Handle an error once

  • Either handle it (retry, use a default, show a message) or return it with context. Do not log it AND return it at every layer, or one failure prints ten log lines.
  • Never ignore errors silently with _ unless you have written down why it is safe.
  • Do not check err.Error() text with strings.Contains; messages change. Use errors.Is or errors.As.
  • Return early on errors so the "happy path" stays at the left edge of the code, not deeply nested.
go
// Hard to read: nested
if err == nil {
	if data, err := parse(raw); err == nil {
		save(data)
	}
}

// Go style: return early
data, err := parse(raw)
if err != nil {
	return fmt.Errorf("parse: %w", err)
}
return save(data)
Early returns keep the normal flow flat and easy to follow.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Errors as values: wrap, Is and As · 1/7

Errors are values

Go has no exceptions. Errors are ordinary values returned from functions.

Prefer a video by a teacher? Find free videos on “golang error handling wrapping errors.Is errors.As tutorial” on YouTube ↗

In real life: Handling a failed payment status check

A payment service calls a bank API. "Transaction not found yet" is normal right after paying and should be retried; a validation error should be shown to the user; anything else is logged as a real failure. Sentinel errors and custom types let the top-level code choose with errors.Is and errors.As, while lower layers just wrap and return.

go
package main

import (
	"errors"
	"fmt"
)

var ErrPending = errors.New("transaction pending")

type BankError struct{ Code int }

func (e *BankError) Error() string { return fmt.Sprintf("bank error %d", e.Code) }

func status(ref string) error {
	switch ref {
	case "T1":
		return ErrPending
	case "T2":
		return &BankError{Code: 503}
	}
	return nil
}

func check(ref string) error {
	if err := status(ref); err != nil {
		return fmt.Errorf("check %s: %w", ref, err)
	}
	return nil
}

func main() {
	for _, ref := range []string{"T1", "T2", "T3"} {
		err := check(ref)
		var be *BankError
		switch {
		case err == nil:
			fmt.Println(ref, "success")
		case errors.Is(err, ErrPending):
			fmt.Println(ref, "pending, retry in 5s")
		case errors.As(err, &be):
			fmt.Println(ref, "bank down, code", be.Code)
		default:
			fmt.Println(ref, "failed:", err)
		}
	}
}
Prints T1 pending, retry in 5s / T2 bank down, code 503 / T3 success.

Practice (0/4 done)

Task 1 · Question

What does this print? err := fmt.Errorf("save: %w", io.EOF) fmt.Println(err == io.EOF, errors.Is(err, io.EOF))

Task 2 · Question

What does this print? err := fmt.Errorf("save: %v", io.EOF) fmt.Println(errors.Is(err, io.EOF))

Task 3 · Question

Which is the idiomatic error message?

Task 4 · Practice

Write func withdraw(balance, amount int64) (int64, error). Return a sentinel ErrInsufficient (wrapped with the amounts using %w) when amount > balance, and a *InvalidAmountError{Amount} when amount <= 0. In main, call it three times and use errors.Is and errors.As to print a different message for each case.

Quick quiz

Question 1

1.Which verb in fmt.Errorf wraps an error so errors.Is can find it?

Question 2

2.What does errors.As do?

Question 3

3.What is a sentinel error?

Question 4

4.Which is the best practice?

Question 5

5.Which Go version added errors.Join?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

Which verb in fmt.Errorf wraps an error so errors.Is can find it?

Answer: %w

%w wraps; %v and %s only copy the text.

What does errors.As do?

Answer: Finds an error of a given type in the chain and assigns it to your variable

errors.As(err, &target) searches the chain for a matching type.

What is a sentinel error?

Answer: A package-level error value callers compare against, like io.EOF

Sentinels are predefined error values checked with errors.Is.

Which is the best practice?

Answer: Handle it once, or wrap and return it

Handle each error exactly once; otherwise wrap it with context and return it.

Which Go version added errors.Join?

Answer: 1.20

errors.Join arrived in Go 1.20, along with support for wrapping several errors.

Before Go 1.22, what did for i := 0; i < 3; i++ { defer func() { fmt.Print(i) }() } print?Related

Answer: 333

All closures shared one i, which was 3 when they ran. Since Go 1.22 it prints 210.

From Go language interview questions

What happens to an exception nobody catches in main?Related

Answer: The thread ends and the stack trace is printed

Uncaught exceptions end the thread.

From Exceptions: try, catch and finally

Why pass the original exception as a cause when wrapping?Related

Answer: So the root problem and its stack trace are not lost

Caused by: lines keep the full story.

From Checked vs unchecked exceptions and throwing your own

A custom exception class should inherit from...Related

Answer: Exception

Custom exceptions derive from Exception (or a more specific exception).

From Exceptions: try, catch, finally and custom exceptions

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 1 more topic in “Pointers, interfaces and errors” before its exam: panic and recover: only for the truly unexpected.
  2. Module 4 exam: Pointers, interfaces and errors · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Packages, testing and generics. 46 topics left in the course before the final exam.

← Previous: any, type assertions and type switches