On this page

Error Handling

12 min read TextCh. 3 — Data and Errors

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.Fatal or os.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.

Use %w to wrap errors and preserve the chain
fmt.Errorf("context: %w", err) creates a new error that wraps the original. errors.Is() and errors.As() can traverse this chain to find specific errors. Without %w (using %v instead), the original error is lost and only the message string remains.
panic/recover is for unrecoverable errors, not normal flow control
panic() in Go is for truly unexpected situations: invariant violations, programming errors, or when continuing would be dangerous. Do not use panic as a replacement for errors. Most Go code should never explicitly call panic or recover.