intermediate Step 8 of 15

Error Handling

Go Programming

Error Handling

Go takes a unique approach to error handling that differs from most other languages. Instead of exceptions and try/catch blocks, Go uses explicit error return values. Functions that can fail return an error as their last return value, and the caller is expected to check it immediately. This approach makes error handling visible and explicit in the code flow, preventing errors from being silently ignored. While it can lead to verbose code, it makes error handling paths clear and easy to follow.

Error Handling Patterns

package main

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

// Functions return errors as the last value
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

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

    // Error wrapping (Go 1.13+)
    data, err := os.ReadFile("config.json")
    if err != nil {
        wrapped := fmt.Errorf("failed to load config: %w", err)
        fmt.Println(wrapped)
        // "failed to load config: open config.json: no such file or directory"

        // Unwrap to check the original error
        if errors.Is(wrapped, os.ErrNotExist) {
            fmt.Println("File does not exist")
        }
    }
    _ = data

    // Converting strings to numbers
    num, err := strconv.Atoi("not-a-number")
    if err != nil {
        fmt.Println("Conversion error:", err)
    }
    _ = num
}

Custom Error Types

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

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

type NotFoundError struct {
    Resource string
    ID       int
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}

func findUser(id int) (*User, error) {
    if id <= 0 {
        return nil, &ValidationError{Field: "id", Message: "must be positive"}
    }
    // Simulate not found
    return nil, &NotFoundError{Resource: "User", ID: id}
}

// Check error type with errors.As
user, err := findUser(999)
if err != nil {
    var notFound *NotFoundError
    if errors.As(err, ¬Found) {
        fmt.Printf("Not found: %s #%d\n", notFound.Resource, notFound.ID)
    }
    var validErr *ValidationError
    if errors.As(err, &validErr) {
        fmt.Printf("Validation: %s - %s\n", validErr.Field, validErr.Message)
    }
}

// Sentinel errors (predefined error values)
var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrForbidden    = errors.New("forbidden")
)

func getResource(id int, role string) (string, error) {
    if role != "admin" {
        return "", fmt.Errorf("access denied: %w", ErrForbidden)
    }
    return "resource data", nil
}
Pro tip: Use fmt.Errorf("context: %w", err) to wrap errors with additional context while preserving the original error for inspection with errors.Is() and errors.As(). This creates a chain of errors that shows exactly where things went wrong: "save user: validate: email: invalid format".

Key Takeaways

  • Go uses explicit error return values instead of exceptions — always check if err != nil.
  • Wrap errors with fmt.Errorf("context: %w", err) to add context while preserving the original error.
  • Use errors.Is() to compare against sentinel errors and errors.As() to extract custom error types.
  • Create custom error types by implementing the error interface (Error() string method).
  • Use defer, panic, and recover only for truly exceptional situations, not normal error handling.