🚨 Chapter 14 of 20

Error Handling

Go handles errors explicitly β€” no exceptions. Errors are values returned alongside results. This deliberate design makes error paths visible, testable, and forces developers to think about failure modes upfront.

⏱ β€”πŸ“Š Intermediate

🎯 Learning Objectives

βœ… After this chapter you will:
  • Understand the error interface and how to create errors
  • Use sentinel errors for known error conditions
  • Create custom error types with additional context
  • Wrap and unwrap errors with fmt.Errorf("%w")
  • Use errors.Is and errors.As for error inspection
  • Know when to use panic and recover
  • Apply production error handling patterns

πŸ”¬ The error Interface

The error type is a built-in interface with a single method. Any type that implements Error() string is an error.

go
// The built-in error interface β€” defined in the universe block
type error interface {
    Error() string
}

// errors.New creates a simple error with a fixed message
import "errors"
err := errors.New("something went wrong")

// fmt.Errorf creates a formatted error message
import "fmt"
name := "config.yaml"
err = fmt.Errorf("file not found: %s", name)
go
package main

import (
    "errors"
    "fmt"
    "os"
)

// Idiomatic: return (value, error) β€” error is always last
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil  // nil error = success
}

func readConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // Add context when propagating errors up the call stack
        return nil, fmt.Errorf("readConfig(%q): %w", path, err)
    }
    return data, nil
}

func main() {
    // Standard error check pattern
    result, err := divide(10, 2)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("10 / 2 =", result)  // 5

    _, err = divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)  // division by zero
    }

    // Multiple operations β€” check each
    _, err = readConfig("/nonexistent/config.yaml")
    if err != nil {
        fmt.Println("Config error:", err)
    }
}
βœ… Output
10 / 2 = 5 Error: division by zero Config error: readConfig("/nonexistent/config.yaml"): open /nonexistent/config.yaml: no such file or directory

πŸ”– Sentinel Errors

Sentinel errors are package-level error values that callers can compare against. They signal well-known error conditions.

go
package main

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

// Define sentinel errors β€” convention: Err prefix
var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrInvalidInput = errors.New("invalid input")
)

type UserStore struct {
    users map[int]string
}

func (s *UserStore) GetUser(id int) (string, error) {
    name, ok := s.users[id]
    if !ok {
        return "", ErrNotFound  // return the sentinel
    }
    return name, nil
}

func main() {
    store := &UserStore{users: map[int]string{1: "Alice", 2: "Bob"}}

    // Compare with errors.Is (works through wrapped errors too)
    name, err := store.GetUser(1)
    fmt.Println(name, err)  // Alice 

    _, err = store.GetUser(99)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found β€” show 404 page")
    }

    // Standard library sentinel errors
    r := strings.NewReader("hello")
    buf := make([]byte, 10)
    n, err := r.Read(buf)
    fmt.Printf("Read %d bytes: %q\n", n, buf[:n])

    _, err = r.Read(buf)
    if errors.Is(err, io.EOF) {
        fmt.Println("End of input reached")
    }
}
βœ… Output
Alice <nil> User not found β€” show 404 page Read 5 bytes: "hello" End of input reached

πŸ—οΈ Custom Error Types

When you need to carry structured data along with the error (HTTP status codes, field names, retry-after times), implement a custom type satisfying the error interface.

go
package main

import (
    "errors"
    "fmt"
    "net/http"
    "time"
)

// Custom error type β€” carries HTTP status + message
type HTTPError struct {
    Code    int
    Message string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}

// Custom error with retry information
type RetryableError struct {
    Cause      error
    RetryAfter time.Duration
}

func (e *RetryableError) Error() string {
    return fmt.Sprintf("%v (retry after %v)", e.Cause, e.RetryAfter)
}

// Unwrap lets errors.Is/As see through the wrapper
func (e *RetryableError) Unwrap() error {
    return e.Cause
}

var ErrRateLimited = errors.New("rate limited")

func fetchData(url string) error {
    // Simulate rate-limited API
    return &RetryableError{
        Cause:      ErrRateLimited,
        RetryAfter: 30 * time.Second,
    }
}

func callAPI(code int) error {
    switch code {
    case http.StatusNotFound:
        return &HTTPError{Code: 404, Message: "resource not found"}
    case http.StatusUnauthorized:
        return &HTTPError{Code: 401, Message: "please login"}
    }
    return nil
}

func main() {
    err := callAPI(404)
    if err != nil {
        // errors.As: extract the concrete type from the error chain
        var httpErr *HTTPError
        if errors.As(err, &httpErr) {
            fmt.Printf("Status %d: %s\n", httpErr.Code, httpErr.Message)
            if httpErr.Code == 404 {
                fmt.Println("Redirecting to home page")
            }
        }
    }

    err = fetchData("https://api.example.com/data")
    var retryErr *RetryableError
    if errors.As(err, &retryErr) {
        fmt.Printf("Rate limited. Retry after: %v\n", retryErr.RetryAfter)
    }

    // errors.Is traverses the Unwrap() chain
    if errors.Is(err, ErrRateLimited) {
        fmt.Println("Is rate limited: true")
    }
}
βœ… Output
Status 404: resource not found Redirecting to home page Rate limited. Retry after: 30s Is rate limited: true

🎁 Error Wrapping β€” Adding Context

Use fmt.Errorf("...%w", err) to wrap an error. Wrapped errors can be inspected with errors.Is and errors.As at any depth.

go
package main

import (
    "errors"
    "fmt"
    "os"
)

var ErrConfig = errors.New("configuration error")

func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // Wrap with %w β€” preserves the original error for Is/As checks
        return nil, fmt.Errorf("readFile(%q): %w", path, err)
    }
    return data, nil
}

func parseConfig(path string) (map[string]string, error) {
    _, err := readFile(path)
    if err != nil {
        // Wrap again β€” build a chain of context
        return nil, fmt.Errorf("parseConfig: %w", err)
    }
    return map[string]string{}, nil
}

func initApp(cfgPath string) error {
    _, err := parseConfig(cfgPath)
    if err != nil {
        return fmt.Errorf("initApp: %w", err)
    }
    return nil
}

func main() {
    err := initApp("/etc/app/config.yaml")
    if err != nil {
        // Full error chain with context at each level
        fmt.Println("Error:", err)

        // Unwrap step by step
        fmt.Println("\nUnwrapping chain:")
        for e := err; e != nil; e = errors.Unwrap(e) {
            fmt.Printf("  β†’ %v\n", e)
        }

        // Check the root cause
        var pathErr *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Printf("\nOS error at path: %q\n", pathErr.Path)
            fmt.Printf("Operation: %s\n", pathErr.Op)
        }
    }
}
βœ… Output
Error: initApp: parseConfig: readFile("/etc/app/config.yaml"): open /etc/app/config.yaml: no such file or directory Unwrapping chain: β†’ initApp: parseConfig: readFile("/etc/app/config.yaml"): open /etc/app/config.yaml: no such file or directory β†’ parseConfig: readFile("/etc/app/config.yaml"): open /etc/app/config.yaml: no such file or directory β†’ readFile("/etc/app/config.yaml"): open /etc/app/config.yaml: no such file or directory β†’ open /etc/app/config.yaml: no such file or directory OS error at path: "/etc/app/config.yaml" Operation: open

πŸ’₯ Panic & Recover

panic stops normal execution and unwinds the stack. recover catches a panic β€” it only works inside a defer. Use sparingly: panics are for truly unrecoverable situations (programmer bugs, not user errors).

go
package main

import "fmt"

// safeDiv: converts a panic into a proper error
func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered panic: %v", r)
        }
    }()
    // This will panic if b == 0 (integer division by zero)
    result = a / b
    return result, nil
}

// mustCompile: panics on programmer error (invalid regex at startup)
func mustCompile(pattern string) string {
    if pattern == "" {
        panic("mustCompile: empty pattern")
    }
    return pattern  // simplified
}

// HTTP middleware: recover from panics so the server stays alive
func withRecovery(handler func()) {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered from panic: %v β€” returning 500\n", r)
            // In real code: write HTTP 500 response
        }
    }()
    handler()
}

func main() {
    // safeDiv wraps the integer division panic
    r, err := safeDiv(10, 2)
    fmt.Println("10/2 =", r, err)  // 5 

    r, err = safeDiv(10, 0)
    fmt.Println("10/0 =", r, err)  // 0 recovered panic: integer divide by zero

    // Panic in a handler β€” recovered by middleware
    withRecovery(func() {
        var m map[string]int  // nil map
        m["key"] = 1          // will panic: assignment to nil map
    })

    fmt.Println("Server continues running after panic recovery")

    // When to panic:
    // βœ… Programmer errors caught at startup (invalid config, impossible state)
    // βœ… Index out of bounds (Go built-in panic)
    // ❌ User input errors β€” return an error instead
    // ❌ Network/IO failures β€” return an error instead
}
βœ… Output
10/2 = 5 <nil> 10/0 = 0 recovered panic: runtime error: integer divide by zero Recovered from panic: assignment to entry in nil map β€” returning 500 Server continues running after panic recovery
⚠️ Panic vs Error β€” The Rule
  • Return an error when the failure is expected or recoverable (IO errors, user input, network issues)
  • Panic only for truly unrecoverable programmer bugs (nil receiver, impossible state, violated invariant)
  • Libraries should never panic on bad input β€” return errors so callers can handle them

🎀 Interview Questions

1
What is the difference between errors.Is and errors.As?
β–Ά
Detailed Answer

errors.Is(err, target): traverses the entire error chain (via Unwrap()) and returns true if any error in the chain is equal to target. Used for comparing against sentinel errors. Example: errors.Is(err, io.EOF).

errors.As(err, &target): traverses the error chain and returns true if any error in the chain can be assigned to target (type assertion). Sets target to the matched error so you can access its fields. Used with custom error types. Example: errors.As(err, &httpErr) where httpErr is *HTTPError.

Key difference: Is checks for a specific value (identity); As checks for a specific type (type assertion). Use Is for sentinel errors, As for custom structured error types.

2
Why does Go use explicit error returns instead of exceptions?
β–Ά
Detailed Answer

Go's error-as-value approach has several advantages over exceptions: (1) Error paths are visible in the code β€” you can see every place that might fail; (2) Errors are values β€” you can store them, pass them, inspect them, and test for them just like any other value; (3) No implicit control flow β€” exceptions can jump from anywhere to anywhere, making code hard to reason about; (4) Forces developers to think about error cases at the point where they occur; (5) Performance β€” no exception handling overhead.

Disadvantages: verbose (if err != nil boilerplate); easy to accidentally ignore errors. The Go team has acknowledged the verbosity and future Go versions may address it.

Go does have panic for truly unrecoverable situations, which is analogous to exceptions but reserved for programmer bugs, not user-facing errors.

πŸ§ͺ Quiz

🧩
Error Handling
5 questions
1. What is the Go built-in error interface?
2. What is the difference between fmt.Errorf("msg: %w", err) and fmt.Errorf("msg: %v", err)?
3. Where must recover() be called to catch a panic?
4. Why should you use errors.Is(err, target) instead of err == target?
5. Should a Go library function panic when it receives invalid input?
β€”

πŸ“Œ Cheat Sheet

πŸ“Œ Error Handling β€” Quick Reference
Simple error
errors.New("message")
Formatted error
fmt.Errorf("ctx: %v", err)
Wrap error
fmt.Errorf("ctx: %w", err)
Check sentinel
errors.Is(err, ErrNotFound)
Extract type
errors.As(err, &target)
Unwrap
errors.Unwrap(err)
Custom type
func (e *T) Error() string { }
Implement Unwrap
func (e *T) Unwrap() error { return e.cause }
Panic
panic("unrecoverable bug")
Recover
defer func() { r := recover() }()
Sentinel
var ErrX = errors.New("...")
Convention
return val, err β€” error is always last