Errors

Go has no exceptions for ordinary failures. A function that can fail returns an error as its last result, and the caller checks it right away. This makes every failure point visible in the code. This lesson covers the error type, creating errors with errors.New and fmt.Errorf, checking and handling them, sentinel errors, and good error messages.

Returning and checking errors

error is a built-in interface with one method, Error() string. A nil error means success:

GO
package main

import (
	"errors"
	"fmt"
)

func divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	result, err := divide(10, 4)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("result:", result)

	if _, err := divide(1, 0); err != nil {
		fmt.Println("error:", err)
	}
}
Output
result: 2.5
error: division by zero

The shape x, err := f() followed by if err != nil is the heartbeat of Go code. Handle the error first and return early; the successful path continues down the left edge of the function.

Creating errors

errors.New makes an error with a fixed message. fmt.Errorf formats one, just like Sprintf:

GO
package main

import (
	"errors"
	"fmt"
)

func checkQuantity(item string, qty, stock int) error {
	if qty <= 0 {
		return errors.New("quantity must be positive")
	}
	if qty > stock {
		return fmt.Errorf("only %d %s in stock, %d requested", stock, item, qty)
	}
	return nil
}

func main() {
	fmt.Println(checkQuantity("lamps", 2, 5))
	fmt.Println(checkQuantity("lamps", 0, 5))
	fmt.Println(checkQuantity("lamps", 9, 5))
}
Output
<nil>
quantity must be positive
only 5 lamps in stock, 9 requested

Writing good error messages

  • Start with a lowercase letter and don't end with punctuation: errors are often joined into longer messages, like loading config: open app.toml: no such file or directory.
  • Say what went wrong and include the values involved: "invalid port %q" is far more useful than "bad input".
  • Add context as an error travels up — what this function was trying to do. The next lesson shows how to do that with %w so the original error can still be inspected.

Handling an error

When you receive an error, you have a few choices. Pick one deliberately — never just ignore it:

OptionWhen
Return it, usually with added contextThe most common choice: the caller knows better what to do
Handle it and carry onUse a default value, retry, or skip a bad record
Log it and carry onNon-critical failures, such as a cache write
Stop the programOnly in main or setup code, when continuing makes no sense
GO
package main

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

func parsePort(s string) (int, error) {
	p, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("invalid port %q", s) // return with context
	}
	if p < 1 || p > 65535 {
		return 0, fmt.Errorf("port %d out of range", p)
	}
	return p, nil
}

func main() {
	port, err := parsePort(os.Getenv("APP_PORT"))
	if err != nil {
		fmt.Println("using default port 8080:", err) // handle: fall back
		port = 8080
	}
	fmt.Println("listening on", port)

	for _, s := range []string{"443", "99999"} {
		if _, err := parsePort(s); err != nil {
			fmt.Fprintln(os.Stderr, "error:", err)
			os.Exit(1) // stop: only in main
		}
		fmt.Println(s, "ok")
	}
}
Output
using default port 8080: invalid port ""
listening on 8080
443 ok
error: port 99999 out of range
exit status 1

os.Exit ends the program immediately with a status code; non-zero means failure to the shell and to scripts. Deferred calls don't run when you call os.Exit, so use it only at the very top level.

Sentinel errors

When callers need to recognise a specific error, a package exports it as a variable — a sentinel error, by convention named Err…. The standard library has many: io.EOF, os.ErrNotExist, sql.ErrNoRows:

GO
package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

var users = map[int]string{1: "ana", 2: "ben"}

func findUser(id int) (string, error) {
	name, ok := users[id]
	if !ok {
		return "", ErrNotFound
	}
	return name, nil
}

func main() {
	for _, id := range []int{2, 7} {
		name, err := findUser(id)
		if errors.Is(err, ErrNotFound) {
			fmt.Println(id, "-> no such user")
			continue
		}
		fmt.Println(id, "->", name)
	}
}
Output
2 -> ben
7 -> no such user

Compare with errors.Is rather than ==; it also matches when the sentinel has been wrapped with extra context (next lesson).

io.EOF: an error that isn't a failure

Some errors signal a normal condition. Reading functions return io.EOF when there's no more input, and callers treat it as "done":

GO
package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"strings"
)

func main() {
	r := bufio.NewReader(strings.NewReader("first\nsecond\nlast"))
	for {
		line, err := r.ReadString('\n')
		if line != "" {
			fmt.Printf("%q\n", line)
		}
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			fmt.Println("read error:", err)
			return
		}
	}
}
Output
"first\n"
"second\n"
"last"

Don't ignore errors

It's possible to throw an error away with _, and occasionally that's right. But ignored errors are a classic source of mysterious bugs — a file that was never written, a value that silently stayed zero. If you deliberately ignore one, add a comment saying why. Tools such as errcheck and staticcheck can find unchecked errors for you.

Try it yourself

Write withdraw with two sentinel errors, and handle each one differently in main.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
took 30 balance 70
rejected: amount must be positive
can't take 500: only 70 left
took 70 balance 0
Next: wrapping errors with context and inspecting them.