On this page
Error Handling
Error Handling in Go: Explicit and Robust
Error handling is one of the most debated and yet most powerful aspects of Go. Go has no exceptions. Instead of try/catch, errors are simply values that functions return. This forces error handling to be explicit and visible in code.
The most common criticism of Go is "there is too much if err != nil". The Go community's response is that this visibility is a feature, not a bug: you can never accidentally ignore an error. In Java, you can forget a catch block and the error propagates silently. In Go, the compiler forces you to handle every error.
The `error` Interface
In Go, error is simply a standard library interface:
type error interface {
Error() string
}Any type with an Error() string method is an error. This makes creating custom errors straightforward.
Creating Simple Errors
import "errors"
// errors.New — the simplest form
var ErrSimple = errors.New("something went wrong")
// fmt.Errorf — with formatting
func validateAge(age int) error {
if age < 0 {
return fmt.Errorf("invalid age: %d (must be >= 0)", age)
}
if age > 150 {
return fmt.Errorf("invalid age: %d (maximum 150)", age)
}
return nil
}The Idiomatic Error Handling Pattern
// Functions that can fail return (result, error)
func readFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("readFile: %w", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("readFile — reading: %w", err)
}
return data, nil
}
// Caller: always check the error before using the result
data, err := readFile("config.json")
if err != nil {
log.Fatal("could not read configuration:", err)
}
// Only safe to use data here
processData(data)Sentinel Errors: Predefined Comparable Values
A sentinel error is a predefined error variable at package level, comparable with == or errors.Is:
// Defining sentinel errors
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("resource conflict")
ErrInvalid = errors.New("invalid parameter")
)
// Usage
func getProduct(id int) (*Product, error) {
if id <= 0 {
return nil, fmt.Errorf("getProduct: %w", ErrInvalid)
}
p, exists := db[id]
if !exists {
return nil, fmt.Errorf("getProduct id=%d: %w", id, ErrNotFound)
}
return p, nil
}
// Compare with errors.Is (traverses the wrapping chain)
p, err := getProduct(0)
if errors.Is(err, ErrInvalid) {
fmt.Println("Invalid ID:", err)
} else if errors.Is(err, ErrNotFound) {
fmt.Println("Product does not exist:", err)
}Sentinel errors are a convention in the standard library: io.EOF, sql.ErrNoRows, os.ErrNotExist, etc.
Custom Errors with Data
When you need to carry additional information in the error, create a custom type:
// Error with HTTP code
type HTTPError struct {
Code int
Message string
Detail string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s — %s", e.Code, e.Message, e.Detail)
}
// Validation error with multiple fields
type ValidationErrors struct {
Fields map[string][]string
}
func (e *ValidationErrors) Error() string {
return fmt.Sprintf("validation failed: %d field(s) with errors", len(e.Fields))
}
func (e *ValidationErrors) Add(field, message string) {
if e.Fields == nil {
e.Fields = make(map[string][]string)
}
e.Fields[field] = append(e.Fields[field], message)
}
func (e *ValidationErrors) HasErrors() bool {
return len(e.Fields) > 0
}`fmt.Errorf` with `%w`: Error Wrapping
The %w verb in fmt.Errorf creates an error that wraps the original, preserving the error chain:
func operationA() error {
return errors.New("database error")
}
func operationB() error {
if err := operationA(); err != nil {
return fmt.Errorf("operationB failed: %w", err) // wraps the error
}
return nil
}
func operationC() error {
if err := operationB(); err != nil {
return fmt.Errorf("operationC failed: %w", err) // wraps again
}
return nil
}
// The complete error chain
err := operationC()
fmt.Println(err)
// "operationC failed: operationB failed: database error"`errors.Is`: Checking Errors in the Chain
errors.Is traverses the wrapping chain looking for a specific error:
var ErrOriginal = errors.New("original error")
err := fmt.Errorf("context: %w", fmt.Errorf("more context: %w", ErrOriginal))
// errors.Is traverses the chain
fmt.Println(errors.Is(err, ErrOriginal)) // true
// == only compares the surface error
fmt.Println(err == ErrOriginal) // false`errors.As`: Extracting the Concrete Type from the Chain
errors.As traverses the wrapping chain looking for an error of a specific type:
type DBError struct {
Query string
Cause error
}
func (e *DBError) Error() string {
return fmt.Sprintf("query %q failed: %v", e.Query, e.Cause)
}
func (e *DBError) Unwrap() error {
return e.Cause
}
// Somewhere deep in a call stack
err := fmt.Errorf("service layer: %w", &DBError{
Query: "SELECT * FROM users",
Cause: errors.New("timeout"),
})
// Extract the DBError from anywhere in the chain
var dbErr *DBError
if errors.As(err, &dbErr) {
fmt.Printf("Failed query: %s\n", dbErr.Query)
}Implementing `Unwrap()` for Custom Errors
For your error to work with errors.Is and errors.As, implement Unwrap():
type ContextError struct {
Message string
Cause error
}
func (e *ContextError) Error() string {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
// Unwrap allows errors.Is and errors.As to traverse the chain
func (e *ContextError) Unwrap() error {
return e.Cause
}`panic` and `recover`: The Last Resort
panic is for truly exceptional situations — invariant violations, conditions that should never occur in production:
func divide(a, b int) int {
if b == 0 {
panic("division by zero: invariant violated")
}
return a / b
}
// recover captures a panic — only useful in defer
func handlePanic() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic recovered:", r)
}
}()
panic("something very bad")
// Never executes:
fmt.Println("this does not print")
}The most legitimate use of recover is in HTTP servers to prevent a panic in one handler from killing the entire server. The Go standard library does this internally.
Errors in Libraries vs. Applications
- Libraries: return rich errors, never call
log.Fataloros.Exit - Applications: you can use
log.Fatal(err)when the error is unrecoverable at startup
// In a library
func Connect(dsn string) (*DB, error) {
// ... return error to the caller to decide what to do
}
// In an application's main
db, err := mylib.Connect(dsn)
if err != nil {
log.Fatalf("could not connect to database: %v", err)
}With error handling mastered, in the next lesson we will enter the heart of Go: goroutines — Go's way of making concurrency accessible and efficient.
Sign in to track your progress