Go Error Handling

Managing errors gracefully in Go programs

⚠️ What is Error Handling?

Go uses explicit error handling through return values rather than exceptions. Functions return an error as the last value, making error handling visible and forcing developers to handle potential failures explicitly.


// Function that can return an error
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Always check for errors
result, err := divide(10, 0)
if err != nil {
    fmt.Println("Error:", err)
}
                                    

Output:

Error: division by zero

Error Concepts

Key Error Concepts

🔍

Error Interface

Built-in error type with Error() method

type error interface {
    Error() string
}

Explicit Checking

Always check if err != nil

if err != nil {
    // Handle error
}
🏗️

Error Creation

Create errors with errors.New()

err := errors.New("something went wrong")
🔗

Error Wrapping

Add context to errors

fmt.Errorf("failed to process: %w", err)

🔹 Basic Error Handling

The fundamental pattern of error handling in Go:

package main

import (
    "errors"
    "fmt"
    "strconv"
)

func parseAge(s string) (int, error) {
    age, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("invalid age format: %s", s)
    }
    
    if age < 0 {
        return 0, errors.New("age cannot be negative")
    }
    
    if age > 150 {
        return 0, errors.New("age seems unrealistic")
    }
    
    return age, nil
}

func main() {
    testCases := []string{"25", "abc", "-5", "200"}
    
    for _, test := range testCases {
        age, err := parseAge(test)
        if err != nil {
            fmt.Printf("Error parsing '%s': %v\n", test, err)
        } else {
            fmt.Printf("Valid age: %d\n", age)
        }
    }
}

Output:

Valid age: 25
Error parsing 'abc': invalid age format: abc
Error parsing '-5': age cannot be negative
Error parsing '200': age seems unrealistic

🔹 Custom Error Types

Create custom error types for more detailed error information:

// Custom error type
type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("validation failed for field '%s' with value '%v': %s", 
        e.Field, e.Value, e.Message)
}

type User struct {
    Name  string
    Email string
    Age   int
}

func validateUser(u User) error {
    if u.Name == "" {
        return ValidationError{
            Field:   "Name",
            Value:   u.Name,
            Message: "name cannot be empty",
        }
    }
    
    if u.Age < 0 || u.Age > 120 {
        return ValidationError{
            Field:   "Age",
            Value:   u.Age,
            Message: "age must be between 0 and 120",
        }
    }
    
    if !strings.Contains(u.Email, "@") {
        return ValidationError{
            Field:   "Email",
            Value:   u.Email,
            Message: "email must contain @ symbol",
        }
    }
    
    return nil
}

func main() {
    users := []User{
        {Name: "John", Email: "[email protected]", Age: 30},
        {Name: "", Email: "[email protected]", Age: 25},
        {Name: "Jane", Email: "invalid-email", Age: 28},
        {Name: "Bob", Email: "[email protected]", Age: -5},
    }
    
    for i, user := range users {
        if err := validateUser(user); err != nil {
            fmt.Printf("User %d validation failed: %v\n", i+1, err)
        } else {
            fmt.Printf("User %d is valid: %s\n", i+1, user.Name)
        }
    }
}

Output:

User 1 is valid: John
User 2 validation failed: validation failed for field 'Name' with value '': name cannot be empty
User 3 validation failed: validation failed for field 'Email' with value 'invalid-email': email must contain @ symbol
User 4 validation failed: validation failed for field 'Age' with value '-5': age must be between 0 and 120

🔹 Error Wrapping and Unwrapping

Add context to errors while preserving the original error:

import (
    "errors"
    "fmt"
)

func readConfig(filename string) error {
    return errors.New("file not found")
}

func loadDatabase() error {
    err := readConfig("database.conf")
    if err != nil {
        return fmt.Errorf("failed to load database config: %w", err)
    }
    return nil
}

func startApplication() error {
    err := loadDatabase()
    if err != nil {
        return fmt.Errorf("application startup failed: %w", err)
    }
    return nil
}

func main() {
    err := startApplication()
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        
        // Unwrap to get the original error
        var originalErr error = err
        for originalErr != nil {
            fmt.Printf("  -> %v\n", originalErr)
            originalErr = errors.Unwrap(originalErr)
        }
        
        // Check if the error chain contains a specific error
        if errors.Is(err, errors.New("file not found")) {
            fmt.Println("Root cause: Configuration file is missing")
        }
    }
}

Output:

Error: application startup failed: failed to load database config: file not found
-> application startup failed: failed to load database config: file not found
-> failed to load database config: file not found
-> file not found

🔹 Error Handling Patterns

Common patterns for handling errors effectively:

// Pattern 1: Early return
func processFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return fmt.Errorf("cannot open file: %w", err)
    }
    defer file.Close()
    
    data, err := io.ReadAll(file)
    if err != nil {
        return fmt.Errorf("cannot read file: %w", err)
    }
    
    if err := processData(data); err != nil {
        return fmt.Errorf("cannot process data: %w", err)
    }
    
    return nil
}

// Pattern 2: Error aggregation
func validateMultiple(items []string) []error {
    var errors []error
    
    for i, item := range items {
        if item == "" {
            errors = append(errors, fmt.Errorf("item %d is empty", i))
        }
        if len(item) > 100 {
            errors = append(errors, fmt.Errorf("item %d is too long", i))
        }
    }
    
    return errors
}

// Pattern 3: Sentinel errors
var (
    ErrNotFound    = errors.New("item not found")
    ErrInvalidInput = errors.New("invalid input")
    ErrTimeout     = errors.New("operation timed out")
)

func findItem(id int) (string, error) {
    if id < 0 {
        return "", ErrInvalidInput
    }
    if id > 1000 {
        return "", ErrNotFound
    }
    return fmt.Sprintf("item-%d", id), nil
}

func main() {
    // Test sentinel errors
    item, err := findItem(-1)
    if err != nil {
        if errors.Is(err, ErrInvalidInput) {
            fmt.Println("Please provide a valid ID")
        } else if errors.Is(err, ErrNotFound) {
            fmt.Println("Item does not exist")
        } else {
            fmt.Printf("Unexpected error: %v\n", err)
        }
    } else {
        fmt.Printf("Found: %s\n", item)
    }
}

Output:

Please provide a valid ID

🧠 Test Your Knowledge

What is the idiomatic way to handle errors in Go?