Error Handling
Go has no exceptions. Instead, functions return error values that callers check explicitly. This reads verbose at first, but pays off in code that's easy to reason about, control flow is linear, and error handling is impossible to "forget".
Learning objectives
- Understand the
errorinterface. - Return, check, and propagate errors.
- Wrap errors with
fmt.Errorf("...: %w", err). - Inspect wrapped errors with
errors.Isanderrors.As. - Define custom error types and sentinel values.
- Know when to use (and especially when not to use)
panic.
Errors are values
error is a built-in interface:
type error interface {
Error() string
}
Any type that implements Error() string satisfies
error. The simplest case:
errors.New("something went wrong").
Returning errors
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("divide by zero")
}
return a / b, nil
}
result, err := divide(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(result)
The convention: error is the last return value;
it's nil on success. Never return a non-nil result
alongside a non-nil error (and vice versa), callers shouldn't have to
check both.
Wrapping with %w
fmt.Errorf with the %w verb wraps an
error, preserving it so callers upstream can inspect it:
func loadConfig(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("loadConfig %q: %w", path, err)
}
// ...
}
Now an error message like loadConfig "a.yaml": open a.yaml: no
such file or directory tells you both what high-level action
failed and the underlying cause, and the original error object is
still accessible.
errors.Is and errors.As
if errors.Is(err, os.ErrNotExist) {
// err chain contains os.ErrNotExist, create the file
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// err chain contains a *os.PathError: pathErr is now usable
fmt.Println(pathErr.Path)
}
errors.Is(err, target): "does err's chain contain this exact error value?"errors.As(err, &v): "does err's chain contain an error of this type? If so, put it in v."
Both walk the chain created by %w. Never compare with
== directly, if the error's been wrapped, ==
will miss it.
Sentinel errors
A package-level error variable used as a comparable "tag":
var ErrNotFound = errors.New("not found")
func find(id int) (*Item, error) {
// ...
return nil, ErrNotFound
}
if errors.Is(err, ErrNotFound) { ... }
Sentinels are great when there's a clear set of known failure modes
callers might want to branch on (io.EOF is the classic).
Use them sparingly, too many sentinels and your package becomes a
jungle.
Custom error types
When the error needs structured data (a code, field, cause):
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Reason)
}
// usage
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Println("bad field:", verr.Field)
}
panic & recover
panic aborts the goroutine and unwinds the stack, running
deferred functions on the way up. recover (used in a
deferred function) stops the unwinding and returns the panic value.
func safeDiv(a, b int) (r int, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("recovered: %v", p)
}
}()
return a / b, nil // panics if b == 0
}
Idioms & anti-patterns
Idioms
- Check errors immediately. Don't stockpile for later.
- Add context when wrapping. "opening config: ..." is more useful than just "file not found".
- Don't log-and-return. Either log or return, not both. The caller logs at the top.
- Use
fmt.Errorffor dynamic errors,errors.Newfor static ones (like sentinels).
Anti-patterns
_ = err: silently ignoring. Almost always a bug.- Comparing with
err.Error() == "no rows": fragile. Useerrors.Is. - Returning
nilfrom an error-returning function by accident. Always check before returning data.
Check your understanding
Practice exercises
Wrap & inspect
Write a function readConfig(path string) (string, error) that reads a file and, on failure, wraps the underlying error with context (e.g. "readConfig: ...: %w"). Call it with a missing path and use errors.Is(err, os.ErrNotExist) to detect a missing file.
Show solution
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("readConfig %q: %w", path, err)
}
return string(b), nil
}
func main() {
_, err := readConfig("missing.yaml")
switch {
case errors.Is(err, os.ErrNotExist):
fmt.Println("config doesn't exist, using defaults")
case err != nil:
fmt.Println("other error:", err)
}
}
Further reading
Errors as first-class values.