Go has no exceptions. A function that can fail returns an error as its last result, and the caller checks it. In this lesson you’ll learn to create errors, return them, add context by wrapping them, and inspect them with errors.Is and errors.As. You’ll also learn when a panic is the right call (rarely).
The error type
error is a built-in interface with a single method:
type error interface {
Error() string
}Code language: Go (go)
Any type that has an Error() string method is an error. You’ll learn how interfaces work in the interfaces lesson; for now it’s enough to know two things. An error value can describe itself as text, and an error that is nil means “no error”.
Checking errors with if err != nil
You already met this in the strings lesson: strconv.Atoi returns a number and an error. The rule is simple. Check the error first, and only use the other results when the error is nil:
package main
import (
"fmt"
"strconv"
)
func main() {
for _, input := range []string{"42", "forty-two"} {
n, err := strconv.Atoi(input)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("got", n)
}
}Code language: Go (go)
got 42
error: strconv.Atoi: parsing "forty-two": invalid syntaxCode language: plaintext (plaintext)
Yes, you’ll write if err != nil a lot. That’s deliberate: every place where something can fail is visible in the code, and the error handling sits right next to the call. You can’t silently ignore the error by accident either, because Go refuses to compile unused variables:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
fmt.Println(n)
}Code language: Go (go)
./main.go:9:5: declared and not used: errCode language: plaintext (plaintext)
You can throw an error away on purpose with n, _ := strconv.Atoi("42"). Do that only when failure truly can’t happen or doesn’t matter, because the _ will look suspicious to anyone reading your code later.
Returning your own errors
To make a function that can fail, add error as its last result. Create an error with errors.New for a fixed message, or fmt.Errorf when you want to format values into it (it takes the same verbs as Printf). On success, return nil for the error:
package main
import (
"errors"
"fmt"
)
func checkAge(age int) error {
if age < 0 {
return errors.New("age cannot be negative")
}
if age > 150 {
return fmt.Errorf("age %d is not realistic", age)
}
return nil
}
func main() {
for _, age := range []int{30, -1, 200} {
if err := checkAge(age); err != nil {
fmt.Println("invalid:", err)
continue
}
fmt.Println(age, "is fine")
}
}Code language: Go (go)
30 is fine
invalid: age cannot be negative
invalid: age 200 is not realisticCode language: plaintext (plaintext)
By convention, error messages start with a lowercase letter and don’t end with punctuation, because they often end up in the middle of a longer message. When a function returns a value and an error, return the zero value for the value when there’s an error: return 0, err.
The usual shape of Go code follows from this: handle the error and return early, and keep the happy path unindented down the left side. Avoid wrapping the success case in an else.
Sentinel errors
Sometimes the caller needs to know which error happened, not just that something failed. A common way to support that is a sentinel error: a package-level variable holding one specific error value. By convention, its name starts with Err:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func findUser(id int) (string, error) {
if id == 1 {
return "erik", nil
}
return "", ErrNotFound
}
func main() {
_, err := findUser(7)
if errors.Is(err, ErrNotFound) {
fmt.Println("no such user, showing the sign-up page")
}
}Code language: Go (go)
no such user, showing the sign-up pageCode language: plaintext (plaintext)
The standard library has plenty of these, such as io.EOF (no more input) and fs.ErrNotExist (file doesn’t exist). You could compare with err == ErrNotFound here, but errors.Is is the better habit, and the next section shows why.
Wrapping errors with %w
An error like not found on its own isn’t very helpful. Not found where, while doing what? As an error travels up through your functions, each layer can add context by wrapping it. Use fmt.Errorf with the %w verb: the new error gets a longer message and also keeps the original error inside it.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("file not found")
func readFile(name string) error {
return fmt.Errorf("read %s: %w", name, ErrNotFound)
}
func loadConfig() error {
if err := readFile("settings.json"); err != nil {
return fmt.Errorf("load config: %w", err)
}
return nil
}
func main() {
err := loadConfig()
fmt.Println(err)
fmt.Println(err == ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
fmt.Println(errors.Unwrap(err))
}Code language: Go (go)
load config: read settings.json: file not found
false
true
read settings.json: file not foundCode language: plaintext (plaintext)
The message now tells the whole story, outside-in. The wrapped error is no longer equal to ErrNotFound, so == says false. errors.Is unwraps layer by layer until it finds a match (or runs out), so it still says true. errors.Unwrap peels off one layer, but you’ll rarely call it directly.
If you use %v instead of %w, you get the same message, but the original error is only copied as text. errors.Is can’t see it anymore. Use %w when callers may want to inspect the cause, and %v when you deliberately want to hide it.
A real example: a missing file
The standard library wraps errors the same way. os.Open on a missing file returns an error that wraps fs.ErrNotExist, so you can check for it without parsing the message:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
_, err := os.Open("missing.txt")
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("no file yet, using defaults")
}
fmt.Println(err)
}Code language: Go (go)
no file yet, using defaults
open missing.txt: no such file or directoryCode language: plaintext (plaintext)
(The exact message comes from the operating system, so on Windows the second line reads differently. The errors.Is check works everywhere, which is one more reason not to compare error strings.)
Custom error types
A sentinel tells you what went wrong, but it can’t carry details. When the caller needs data, such as which field failed validation, define your own error type. It’s a struct with an Error() string method; both are covered properly in stage 3, so just follow the shape for now:
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Problem string
}
func (e *ValidationError) Error() string {
return e.Field + " " + e.Problem
}
func validateEmail(email string) error {
if email == "" {
return &ValidationError{Field: "email", Problem: "is required"}
}
return nil
}
func signUp(email string) error {
if err := validateEmail(email); err != nil {
return fmt.Errorf("sign up: %w", err)
}
return nil
}
func main() {
err := signUp("")
fmt.Println(err)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("please fix the field:", ve.Field)
}
}Code language: Go (go)
sign up: email is required
please fix the field: emailCode language: plaintext (plaintext)
errors.As is to types what errors.Is is to values. It walks through the wrapped layers looking for an error of the type of its target. If it finds one, it stores it in the target (that’s why you pass &ve, a pointer) and returns true. Now you have the full ValidationError with all its fields.
errors.AsType
Go 1.26 added errors.AsType, a generic version of errors.As that needs no separate variable. You put the type you’re looking for in square brackets, and it returns the error and a boolean:
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Problem string
}
func (e *ValidationError) Error() string {
return e.Field + " " + e.Problem
}
func main() {
err := fmt.Errorf("sign up: %w", &ValidationError{Field: "age", Problem: "must be a number"})
if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println("bad field:", ve.Field)
}
}Code language: Go (go)
bad field: ageCode language: plaintext (plaintext)
It fits neatly into an if with a short statement, and the compiler checks that the type you ask for is actually an error. The square brackets are type arguments, explained in the generics lesson. In code that targets Go 1.26 or later I prefer AsType; you’ll see errors.As in most existing code.
Combining errors with errors.Join
Sometimes several things fail at once, for example when you validate a whole form and want to report every problem, not just the first. errors.Join combines errors into one. Its message puts each error on its own line, and errors.Is and errors.As check all of them. nil errors are skipped, and if all of them are nil, the result is nil:
package main
import (
"errors"
"fmt"
)
var (
ErrNoName = errors.New("name is required")
ErrBadAge = errors.New("age must be positive")
ErrNoEmail = errors.New("email is required")
)
func validate(name string, age int, email string) error {
var errs []error
if name == "" {
errs = append(errs, ErrNoName)
}
if age <= 0 {
errs = append(errs, ErrBadAge)
}
if email == "" {
errs = append(errs, ErrNoEmail)
}
return errors.Join(errs...)
}
func main() {
err := validate("", -3, "[email protected]")
fmt.Println(err)
fmt.Println(errors.Is(err, ErrBadAge), errors.Is(err, ErrNoEmail))
fmt.Println(validate("Erik", 42, "[email protected]"))
}Code language: Go (go)
name is required
age must be positive
true false
<nil>Code language: plaintext (plaintext)
fmt.Errorf can also wrap more than one error by using %w several times in the format string.
When to panic instead
You saw panic and recover in the defer lesson. Errors and panics are for different things:
- Return an
errorfor anything that can go wrong in normal operation: bad input, missing files, network problems, a record that doesn’t exist. The caller decides what to do. - Panic only for bugs and impossible states, where continuing would be wrong: a function called with arguments that break its documented rules, or a corrupted invariant. The runtime panics on things like out-of-range indexes for the same reason.
- Panicking during startup is acceptable when the program can’t do anything useful without what failed. That’s why the standard library has functions like
regexp.MustCompile, which panic instead of returning an error and are meant for values fixed in your source code.
When in doubt, return an error. A library that panics on bad input takes the decision away from the program using it.
Practice
1. Withdraw money. Write withdraw(balance, amount int) (int, error) that returns the new balance. Return the sentinel ErrInsufficientFunds wrapped with the amounts (for example withdraw 80: balance 50: insufficient funds) when the balance is too low, and a plain error for amounts of zero or less. In main, try a few amounts and use errors.Is to print a friendly message for insufficient funds.
Show a solution
package main
import (
"errors"
"fmt"
)
var ErrInsufficientFunds = errors.New("insufficient funds")
func withdraw(balance, amount int) (int, error) {
if amount <= 0 {
return balance, fmt.Errorf("invalid amount %d", amount)
}
if amount > balance {
return balance, fmt.Errorf("withdraw %d: balance %d: %w", amount, balance, ErrInsufficientFunds)
}
return balance - amount, nil
}
func main() {
balance := 50
for _, amount := range []int{20, 80, -5} {
newBalance, err := withdraw(balance, amount)
switch {
case errors.Is(err, ErrInsufficientFunds):
fmt.Println("sorry, not enough money:", err)
case err != nil:
fmt.Println("error:", err)
default:
balance = newBalance
fmt.Println("ok, balance is now", balance)
}
}
}Code language: Go (go)
ok, balance is now 30
sorry, not enough money: withdraw 80: balance 30: insufficient funds
error: invalid amount -5Code language: plaintext (plaintext)2. Parse a port. Write parsePort(s string) (int, error) that uses strconv.Atoi and wraps its error with fmt.Errorf("parse port %q: %w", s, err), and returns a *RangeError (a custom type with fields Value, Min and Max) when the number isn’t between 1 and 65535. In main, use errors.AsType to detect the range error and print its limits. Bonus: strconv errors are of type *strconv.NumError; detect that one too.
Show a solution
package main
import (
"errors"
"fmt"
"strconv"
)
type RangeError struct {
Value, Min, Max int
}
func (e *RangeError) Error() string {
return fmt.Sprintf("%d is out of range [%d, %d]", e.Value, e.Min, e.Max)
}
func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse port %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, &RangeError{Value: n, Min: 1, Max: 65535}
}
return n, nil
}
func main() {
for _, s := range []string{"8080", "70000", "http"} {
port, err := parsePort(s)
if err == nil {
fmt.Println("port", port)
continue
}
if re, ok := errors.AsType[*RangeError](err); ok {
fmt.Printf("pick a port from %d to %d (got %d)\n", re.Min, re.Max, re.Value)
} else if ne, ok := errors.AsType[*strconv.NumError](err); ok {
fmt.Printf("%q is not a number\n", ne.Num)
}
fmt.Println(" error:", err)
}
}Code language: Go (go)
port 8080
pick a port from 1 to 65535 (got 70000)
error: 70000 is out of range [1, 65535]
"http" is not a number
error: parse port "http": strconv.Atoi: parsing "http": invalid syntaxCode language: plaintext (plaintext)Key takeaways
- Functions that can fail return an
erroras their last result;nilmeans success. Check it right away withif err != niland return early. - Create errors with
errors.Neworfmt.Errorf. Add context as errors travel up by wrapping with%w. - Use
errors.Isto check for a specific error value (sentinels likefs.ErrNotExist), anderrors.Asorerrors.AsTypeto get at an error of a specific type. Both look through every wrapped layer. errors.Joincombines several errors into one.- Panic only for bugs and impossible states. Everything else is an error.