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:
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) } }
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:
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)) }
<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
%wso 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:
| Option | When |
|---|---|
| Return it, usually with added context | The most common choice: the caller knows better what to do |
| Handle it and carry on | Use a default value, retry, or skip a bad record |
| Log it and carry on | Non-critical failures, such as a cache write |
| Stop the program | Only in main or setup code, when continuing makes no sense |
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") } }
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:
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) } }
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":
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 } } }
"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.
took 30 balance 70 rejected: amount must be positive can't take 500: only 70 left took 70 balance 0