Go · 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.