Skip to main content
Go · lesson 13 of 25
Go intermediate Lesson 13 of 25

Error Handling in Go

Go errors run for real: errors as values, wrapping with %w vs %v, errors.Is and errors.As through a chain, custom types, errors.Join, and panic vs recover.

By SkillByExample · Updated

In Go, a function that can fail returns an error as its last result, and the caller checks it with if err != nil. This lesson builds from that one convention to wrapping, inspection, custom error types and the narrow role of panic.

The error interface

error is a one-method interface:

type error interface {
	Error() string
}

The standard library’s errors already say what failed:

package main

import (
	"fmt"
	"os"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("12x")
	if err != nil {
		fmt.Println("parse:", err)
	}
	fmt.Println("n is", n)

	_, err = os.Open("/no/such/file.txt")
	if err != nil {
		fmt.Println("open:", err)
		fmt.Printf("type: %T\n", err)
		return
	}
}
$ go run basic.go
parse: strconv.Atoi: parsing "12x": invalid syntax
n is 0
open: open /no/such/file.txt: no such file or directory
type: *fs.PathError

When there is an error, the other result is normally the zero value and shouldn’t be used — Atoi returned 0. %T shows that os.Open returned a *fs.PathError, a struct carrying the operation, the path and the underlying cause, all of which ended up in the message.

Creating errors

errors.New makes a fixed message; fmt.Errorf formats one. Package-level error variables, called sentinels, let callers check for a specific condition.

package main

import (
	"errors"
	"fmt"
)

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

func withdraw(balance, amount float64) (float64, error) {
	if amount <= 0 {
		return balance, fmt.Errorf("invalid amount %.2f", amount)
	}
	if amount > balance {
		return balance, ErrInsufficientFunds
	}
	return balance - amount, nil
}

func main() {
	for _, amt := range []float64{30, -5, 500} {
		b, err := withdraw(100, amt)
		if err == ErrInsufficientFunds {
			fmt.Println("declined:", err)
			continue
		}
		fmt.Println(b, err)
	}
}
$ go run create.go
70 <nil>
100 invalid amount -5.00
declined: insufficient funds

Comparing with == works here because withdraw returns the sentinel itself. As soon as another layer adds context, it stops working — which is what wrapping fixes.

Wrapping: %w vs %v

fmt.Errorf with %w builds a new error that contains the original. %v only copies its text.

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func readConfig(path string) error {
	_, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read config: %w", err)
	}
	return nil
}

func start() error {
	if err := readConfig("/etc/app/missing.yaml"); err != nil {
		return fmt.Errorf("start: %w", err)
	}
	return nil
}

func main() {
	err := start()
	fmt.Println(err)
	fmt.Println("== ErrNotExist:", err == fs.ErrNotExist)
	fmt.Println("Is ErrNotExist:", errors.Is(err, fs.ErrNotExist))

	flat := fmt.Errorf("start: %v", err)
	fmt.Println("Is (after %v):", errors.Is(flat, fs.ErrNotExist))
}
$ go run wrap.go
start: read config: open /etc/app/missing.yaml: no such file or directory
== ErrNotExist: false
Is ErrNotExist: true
Is (after %v): false

Each layer prefixed its context, giving a message that reads like a trail: start: read config: open .... == fails because err is the outermost wrapper, not the sentinel. errors.Is unwraps layer by layer — through both %w wrappers and into the *fs.PathError, which reports it matches fs.ErrNotExist — and finds it. The %v version has an identical message but no chain, so the check fails. Use %w when callers may need the cause; %v when you deliberately want to hide it.

errors.As: getting at a typed error

errors.As searches the chain for an error of a given type and stores it in your variable.

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	_, err := os.Open("/no/such/dir/data.csv")
	err = fmt.Errorf("load data: %w", err)

	var pathErr *fs.PathError
	if errors.As(err, &pathErr) {
		fmt.Println("op:  ", pathErr.Op)
		fmt.Println("path:", pathErr.Path)
		fmt.Println("err: ", pathErr.Err)
	}
}
$ go run as.go
op:   open
path: /no/such/dir/data.csv
err:  no such file or directory

Pass a pointer to a variable of the target type (here **fs.PathError). errors.As finds the *fs.PathError beneath the wrapper and fills in pathErr, so you can read structured fields instead of parsing the message.

Since Go 1.26, the generic errors.AsType does the same without the separate variable. You name the type in brackets, and it returns the match and a bool:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	_, err := os.Open("/no/such/dir/data.csv")
	err = fmt.Errorf("load data: %w", err)

	if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
		fmt.Println("path:", pathErr.Path)
	}
	_, ok := errors.AsType[*fs.PathError](errors.New("plain"))
	fmt.Println("plain error matched:", ok)
}
$ go run astype.go
path: /no/such/dir/data.csv
plain error matched: false

The comma-ok form keeps pathErr scoped to the if. The type in brackets must be an error type, and the compiler checks that. Here is the same lookup with the * left off:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	_, err := os.Open("/no/such/dir/data.csv")

	if pathErr, ok := errors.AsType[fs.PathError](err); ok { // no *
		fmt.Println("path:", pathErr.Path)
	}
}
$ go run astype_bad.go
# command-line-arguments
./astype_bad.go:13:34: fs.PathError does not satisfy error (method Error has pointer receiver)

Error is defined on *fs.PathError, so only the pointer type is an error. With errors.As, the same mistake (var pathErr fs.PathError; errors.As(err, &pathErr)) compiles and then panics at runtime. With AsType it can’t compile. The fix is the * from the example above: errors.AsType[*fs.PathError](err). errors.As still works and appears in most existing code.

Custom error types

When callers need data about the failure, define a type with an Error() method.

package main

import (
	"errors"
	"fmt"
)

type ValidationError struct {
	Field, Msg string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}

func register(email string, age int) error {
	if age < 18 {
		return &ValidationError{"age", "must be at least 18"}
	}
	if email == "" {
		return &ValidationError{"email", "required"}
	}
	return nil
}

func main() {
	err := fmt.Errorf("register: %w", register("ann@example.com", 15))
	fmt.Println(err)

	var ve *ValidationError
	if errors.As(err, &ve) {
		fmt.Printf("highlight field %q\n", ve.Field)
	}
	fmt.Println(register("bo@example.com", 30) == nil)
}
$ go run custom.go
register: age: must be at least 18
highlight field "age"
true

The method has a pointer receiver, so *ValidationError is the type that implements error, and that’s the type to pass to errors.As. Returning the literal nil on success matters: returning a nil *ValidationError variable instead would produce a non-nil error — the trap shown in the interfaces lesson.

Joining errors

errors.Join (Go 1.20+) combines several errors into one, for example when validating every field instead of stopping at the first.

package main

import (
	"errors"
	"fmt"
)

var (
	ErrNoName = errors.New("name is required")
	ErrBadAge = errors.New("age must be positive")
)

func validate(name string, age int) error {
	var errs []error
	if name == "" {
		errs = append(errs, ErrNoName)
	}
	if age <= 0 {
		errs = append(errs, ErrBadAge)
	}
	return errors.Join(errs...)
}

func main() {
	err := validate("", -1)
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrBadAge))
	fmt.Println(validate("ann", 3) == nil)
}
$ go run join.go
name is required
age must be positive
true
true

The joined message puts each error on its own line, and errors.Is matches any of them. errors.Join with no non-nil arguments returns nil, so the success path needs no special case.

panic and recover

A panic unwinds the stack running deferred calls; if nothing recovers, the program crashes with a stack trace.

package main

import "fmt"

func mustPositive(n int) int {
	if n <= 0 {
		panic(fmt.Sprintf("mustPositive: got %d", n))
	}
	return n
}

func main() {
	defer fmt.Println("deferred calls still run")
	fmt.Println(mustPositive(3))
	fmt.Println(mustPositive(-1))
	fmt.Println("never printed")
}
$ go run panic.go
3
deferred calls still run
panic: mustPositive: got -1

goroutine 1 [running]:
main.mustPositive(...)
	/home/user/gobyex/error-handling/panic/panic.go:7
main.main()
	/home/user/gobyex/error-handling/panic/panic.go:15 +0xec
exit status 2

The deferred Println ran before the crash report. recover, called inside a deferred function, stops the unwinding and turns the panic into a value — useful at a boundary such as an HTTP handler, so one bad request can’t kill the server:

package main

import (
	"errors"
	"fmt"
)

func safely(name string, f func()) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("%s panicked: %v", name, r)
		}
	}()
	f()
	return nil
}

func main() {
	fmt.Println(safely("ok", func() {}))
	fmt.Println(safely("index", func() {
		var s []int
		_ = s[3]
	}))
	err := safely("nil map", func() {
		var m map[string]int
		m["a"] = 1
	})
	fmt.Println(err)
	fmt.Println(errors.Unwrap(err))
}
$ go run recover.go
<nil>
index panicked: runtime error: index out of range [3] with length 0
nil map panicked: assignment to entry in nil map
<nil>

Runtime failures such as an out-of-range index or a nil-map write are panics, and recover caught both. The last line prints <nil> because the error was built with %v, not %w — the panic value isn’t an error chain. Use this pattern only at boundaries; ordinary failures should be returned as errors, not panicked.

Practice

1. Change withdraw in create.go to wrap the sentinel with the amounts — fmt.Errorf("withdraw %.2f from %.2f: %w", ...) — and update main so the check still works.

Solution
package main

import (
	"errors"
	"fmt"
)

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

func withdraw(balance, amount float64) (float64, error) {
	if amount > balance {
		return balance, fmt.Errorf("withdraw %.2f from %.2f: %w", amount, balance, ErrInsufficientFunds)
	}
	return balance - amount, nil
}

func main() {
	_, err := withdraw(100, 500)
	fmt.Println(err)
	fmt.Println(err == ErrInsufficientFunds, errors.Is(err, ErrInsufficientFunds))
}
$ go run p1.go
withdraw 500.00 from 100.00: insufficient funds
false true

The message gains context, == stops matching, and errors.Is keeps working.

2. Add an Unwrap() error method to a custom QueryError{Query string; Err error} so that errors.Is(err, sql.ErrNoRows) sees through it.

Solution
package main

import (
	"database/sql"
	"errors"
	"fmt"
)

type QueryError struct {
	Query string
	Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

func main() {
	var err error = &QueryError{"SELECT * FROM users WHERE id=7", sql.ErrNoRows}
	fmt.Println(err)
	fmt.Println(errors.Is(err, sql.ErrNoRows))
}
$ go run p2.go
SELECT * FROM users WHERE id=7: sql: no rows in result set
true

Unwrap is how errors.Is and errors.As step into a custom type; fmt.Errorf("%w") generates the same method for you.

3. Make validate in join.go also reject names longer than 10 characters with a *ValidationError-style typed error, then use errors.As on the joined result to read its field.

Solution
package main

import (
	"errors"
	"fmt"
)

type FieldError struct{ Field string }

func (e *FieldError) Error() string { return e.Field + " is invalid" }

var ErrBadAge = errors.New("age must be positive")

func validate(name string, age int) error {
	var errs []error
	if len(name) > 10 {
		errs = append(errs, &FieldError{"name"})
	}
	if age <= 0 {
		errs = append(errs, ErrBadAge)
	}
	return errors.Join(errs...)
}

func main() {
	err := validate("Bartholomew", 0)
	var fe *FieldError
	if errors.As(err, &fe) {
		fmt.Println("bad field:", fe.Field)
	}
	fmt.Println(errors.Is(err, ErrBadAge))
}
$ go run p3.go
bad field: name
true

errors.As and errors.Is both search every branch of a joined error.

4. Use safely from recover.go to run a function that divides by a zero variable, and print the resulting error.

Solution
package main

import "fmt"

func safely(f func()) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	f()
	return nil
}

func main() {
	zero := 0
	fmt.Println(safely(func() { fmt.Println(10 / zero) }))
}
$ go run p4.go
recovered: runtime error: integer divide by zero

Dividing by a constant zero is a compile error; dividing by a variable that holds zero is a run-time panic.

Frequently Asked Questions

Why does Go use errors as values instead of exceptions?
Treating failure as an ordinary return value keeps every error path visible at the call site: the caller sees err and decides what to do on the next line. Exceptions can unwind through many functions invisibly. Go reserves panic for programmer errors and unrecoverable states.
What is the difference between %w and %v in fmt.Errorf?
Both put the original error's message into the new one. Only %w also records the original as the wrapped cause, so errors.Is and errors.As can find it. Use %w when callers may need to inspect the cause, and %v to deliberately hide an implementation detail.
What is the difference between errors.Is and errors.As?
errors.Is(err, target) walks the wrap chain looking for an error equal to a specific value, such as a sentinel like os.ErrNotExist. errors.As(err, &target) walks the chain looking for an error of a given type and, when it finds one, stores it in target so you can read its fields.
How do I return several errors at once in Go?
Use errors.Join, added in Go 1.20. It combines errors into one whose message lists each on its own line, and errors.Is and errors.As match any of them. It returns nil if every argument is nil, so it is safe to call with optional errors.
When should I use panic in Go?
Rarely: for bugs that should never happen, such as an impossible switch case or a broken invariant, and for failures during program start-up that make running pointless. Expected failures like bad input, missing files or network errors should be returned as error values.