DEV Community

Nguyễn Phú
Nguyễn Phú

Posted on

Error Handling in Go: Patterns and Best Practices

Go's Unique Approach to Error Handling

Go takes a fundamentally different approach to error handling compared to languages with exceptions. Instead of throwing and catching exceptions, Go uses explicit error returns - errors are values that are returned from functions, making error handling visible and explicit in the code.

This design philosophy has several benefits:

  • Explicit: Errors are visible in function signatures and call sites
  • Simple: No hidden control flow or stack unwinding
  • Composable: Errors can be wrapped, checked, and transformed
  • Predictable: Error handling is part of the normal code flow

The Error Interface

At the heart of Go's error handling is the error interface, which is incredibly simple:

type error interface {
    Error() string
}
Enter fullscreen mode Exit fullscreen mode

Any type that implements this interface is an error. The Error() method returns a string description of the error.

package main

import (
    "fmt"
    "errors"
)

func main() {
    err := errors.New("something went wrong")
    fmt.Println(err.Error()) // "something went wrong"
    fmt.Println(err)         // "something went wrong" (fmt.Println calls Error() automatically)
}
Enter fullscreen mode Exit fullscreen mode

Creating Errors

Go provides several ways to create errors, each suitable for different scenarios.

Using errors.New()

The simplest way to create an error is with errors.New():

package main

import (
    "errors"
    "fmt"
)

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

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("Result: %f\n", result)
}
Enter fullscreen mode Exit fullscreen mode

Output:

Error: division by zero
Enter fullscreen mode Exit fullscreen mode

Using fmt.Errorf()

For formatted error messages, use fmt.Errorf():

package main

import (
    "fmt"
)

func getUser(id int) (string, error) {
    if id < 0 {
        return "", fmt.Errorf("invalid user ID: %d (must be positive)", id)
    }
    // ... fetch user
    return "user", nil
}

func main() {
    _, err := getUser(-1)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Error: invalid user ID: -1 (must be positive)
Enter fullscreen mode Exit fullscreen mode

Checking Errors

The idiomatic way to handle errors in Go is to check them explicitly:

result, err := someFunction()
if err != nil {
    // Handle the error
    return err // or handle it appropriately
}
// Continue with result
Enter fullscreen mode Exit fullscreen mode

Common Error Checking Patterns

Pattern 1: Return Early

func processUser(id int) error {
    user, err := getUser(id)
    if err != nil {
        return err // Return immediately
    }

    err = validateUser(user)
    if err != nil {
        return err
    }

    return saveUser(user)
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Log and Continue

func processUsers(ids []int) {
    for _, id := range ids {
        user, err := getUser(id)
        if err != nil {
            log.Printf("Failed to get user %d: %v", id, err)
            continue // Skip this user, continue with next
        }
        processUser(user)
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Handle Specific Errors

func processFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        if os.IsNotExist(err) {
            return fmt.Errorf("file %s does not exist", filename)
        }
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close()
    // ... process file
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Error Wrapping (Go 1.13+)

Go 1.13 introduced error wrapping, allowing you to add context to errors while preserving the original error for inspection.

The %w Verb

Use fmt.Errorf() with the %w verb to wrap errors:

package main

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

func readConfig(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return fmt.Errorf("failed to open config file: %w", err)
    }
    defer file.Close()

    // ... read config
    return nil
}

func main() {
    err := readConfig("config.json")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        // Output: Error: failed to open config file: open config.json: no such file or directory
    }
}
Enter fullscreen mode Exit fullscreen mode

The wrapped error preserves the original error, allowing you to inspect the error chain.

errors.Unwrap()

The errors.Unwrap() function retrieves the wrapped error:

package main

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

func main() {
    err := fmt.Errorf("failed to open: %w", os.ErrNotExist)
    unwrapped := errors.Unwrap(err)
    fmt.Println(unwrapped == os.ErrNotExist) // true
}
Enter fullscreen mode Exit fullscreen mode

errors.Is()

The errors.Is() function checks if any error in the error chain matches a target:

package main

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

func readFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close()

    data := make([]byte, 100)
    _, err = file.Read(data)
    if err != nil {
        return fmt.Errorf("failed to read file: %w", err)
    }
    return nil
}

func main() {
    err := readFile("example.txt")

    // Check if the error chain contains io.EOF
    if errors.Is(err, io.EOF) {
        fmt.Println("Reached end of file")
    }

    // Check if the error chain contains os.ErrNotExist
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("File does not exist")
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • errors.Is() traverses the entire error chain
  • Works with wrapped errors created with %w
  • Use for checking sentinel errors

errors.As()

The errors.As() function checks if any error in the chain is of a specific type and extracts it:

package main

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

func main() {
    err := fmt.Errorf("operation failed: %w", &os.PathError{
        Op:   "open",
        Path: "file.txt",
        Err:  syscall.ENOENT,
    })

    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Printf("Path: %s\n", pathErr.Path)
        fmt.Printf("Operation: %s\n", pathErr.Op)
        fmt.Printf("Error: %v\n", pathErr.Err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • errors.As() extracts the error type from the chain
  • The second argument must be a pointer to the error type
  • Use for checking custom error types

Custom Error Types

For structured error information, create custom error types:

package main

import (
    "fmt"
)

// Custom error type with additional fields
type ValidationError struct {
    Field   string
    Message string
}

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

func validateUser(name, email string) error {
    if name == "" {
        return &ValidationError{
            Field:   "name",
            Message: "name is required",
        }
    }
    if email == "" {
        return &ValidationError{
            Field:   "email",
            Message: "email is required",
        }
    }
    return nil
}

func main() {
    err := validateUser("", "")
    if err != nil {
        fmt.Println(err)

        // Check if it's a ValidationError
        var valErr *ValidationError
        if errors.As(err, &valErr) {
            fmt.Printf("Field: %s\n", valErr.Field)
            fmt.Printf("Message: %s\n", valErr.Message)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

validation error on field 'name': name is required
Field: name
Message: name is required
Enter fullscreen mode Exit fullscreen mode

When to Use Custom Error Types

Use custom error types when you need:

  • Structured information: Multiple fields beyond a message
  • Type checking: Different handling based on error type
  • Additional methods: Behavior specific to the error type

Sentinel Errors

Sentinel errors are predefined error values that represent specific error conditions. They're typically declared at package level:

package main

import (
    "errors"
    "fmt"
)

// Sentinel errors
var (
    ErrUserNotFound    = errors.New("user not found")
    ErrInvalidPassword = errors.New("invalid password")
    ErrUnauthorized    = errors.New("unauthorized")
)

func authenticate(username, password string) error {
    user, err := findUser(username)
    if err != nil {
        return ErrUserNotFound
    }

    if !validatePassword(user, password) {
        return ErrInvalidPassword
    }

    return nil
}

func main() {
    err := authenticate("user", "wrong")
    if errors.Is(err, ErrInvalidPassword) {
        fmt.Println("Password is incorrect")
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Sentinel Errors in Standard Library

The Go standard library provides many sentinel errors:

import (
    "io"
    "os"
)

// Check for end of file
if errors.Is(err, io.EOF) {
    // Handle EOF
}

// Check if file doesn't exist
if errors.Is(err, os.ErrNotExist) {
    // Handle file not found
}

// Check if permission denied
if errors.Is(err, os.ErrPermission) {
    // Handle permission error
}
Enter fullscreen mode Exit fullscreen mode

Best Practices for Sentinel Errors:

  • Use for expected errors that callers should handle
  • Make them exported (capitalized) so callers can check them
  • Use descriptive names starting with Err
  • Document when they're returned

Error Handling Patterns

Pattern 1: Error Propagation

Return errors up the call stack, adding context at each level:

func processOrder(orderID int) error {
    order, err := getOrder(orderID)
    if err != nil {
        return fmt.Errorf("failed to get order %d: %w", orderID, err)
    }

    err = validateOrder(order)
    if err != nil {
        return fmt.Errorf("order %d validation failed: %w", orderID, err)
    }

    err = saveOrder(order)
    if err != nil {
        return fmt.Errorf("failed to save order %d: %w", orderID, err)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Error Wrapping with Context

Add context at each level while preserving the original error:

func fetchUserData(userID int) (*UserData, error) {
    user, err := getUser(userID)
    if err != nil {
        return nil, fmt.Errorf("fetchUserData: failed to get user: %w", err)
    }

    profile, err := getUserProfile(userID)
    if err != nil {
        return nil, fmt.Errorf("fetchUserData: failed to get profile: %w", err)
    }

    return &UserData{User: user, Profile: profile}, nil
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Structured Error Handling

Use custom error types for structured error information:

type APIError struct {
    Code    int
    Message string
    Details map[string]interface{}
}

func (e *APIError) Error() string {
    return fmt.Sprintf("API error [%d]: %s", e.Code, e.Message)
}

func makeAPIRequest(url string) error {
    // ... make request
    if statusCode == 404 {
        return &APIError{
            Code:    404,
            Message: "Resource not found",
            Details: map[string]interface{}{
                "url": url,
            },
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Panic and Recover

While Go uses explicit error returns for normal error handling, panic and recover exist for truly exceptional situations.

When to Use Panic

Use panic for:

  • Programming errors: Bugs that should be fixed, not handled
  • Unrecoverable situations: When the program cannot continue
  • Invariant violations: When assumptions are violated

Examples of appropriate panic usage:

// Programming error - should be fixed
func divide(a, b int) int {
    if b == 0 {
        panic("division by zero") // This is a bug, should be checked before calling
    }
    return a / b
}

// Invariant violation
func (s *Stack) Pop() int {
    if s.isEmpty() {
        panic("pop from empty stack") // Programming error
    }
    // ... pop logic
}
Enter fullscreen mode Exit fullscreen mode

Recover

recover can only be used inside a defer function to catch panics:

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()

    result = a / b // This might panic if b == 0
    return result, nil
}

func main() {
    result, err := safeDivide(10, 0)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
    } else {
        fmt.Printf("Result: %d\n", result)
    }
}
Enter fullscreen mode Exit fullscreen mode

Important Notes:

  • recover only works in deferred functions
  • Use recover sparingly - typically only at package boundaries
  • Don't use panic for normal error conditions - use error returns instead

Common Pitfalls

Pitfall 1: Ignoring Errors

❌ BAD:

file, _ := os.Open("file.txt") // Error ignored!
defer file.Close()
Enter fullscreen mode Exit fullscreen mode

✅ GOOD:

file, err := os.Open("file.txt")
if err != nil {
    return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Overusing Panic

❌ BAD:

func getUser(id int) *User {
    user, err := fetchUser(id)
    if err != nil {
        panic(err) // Don't panic for normal errors!
    }
    return user
}
Enter fullscreen mode Exit fullscreen mode

✅ GOOD:

func getUser(id int) (*User, error) {
    user, err := fetchUser(id)
    if err != nil {
        return nil, fmt.Errorf("failed to fetch user: %w", err)
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Poor Error Messages

❌ BAD:

return errors.New("error")
Enter fullscreen mode Exit fullscreen mode

✅ GOOD:

return fmt.Errorf("failed to connect to database at %s: %w", dbURL, err)
Enter fullscreen mode Exit fullscreen mode

Pitfall 4: Not Adding Context

❌ BAD:

func processOrder(orderID int) error {
    err := saveOrder(orderID)
    return err // No context about what operation failed
}
Enter fullscreen mode Exit fullscreen mode

✅ GOOD:

func processOrder(orderID int) error {
    err := saveOrder(orderID)
    if err != nil {
        return fmt.Errorf("failed to save order %d: %w", orderID, err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Always Check Errors

Never ignore errors. If you're not handling an error, at least log it:

result, err := someFunction()
if err != nil {
    log.Printf("Warning: %v", err) // At minimum, log it
    // Or handle it appropriately
}
Enter fullscreen mode Exit fullscreen mode

2. Add Context When Wrapping

When wrapping errors, add meaningful context:

// Good: Adds context
return fmt.Errorf("failed to process user %d: %w", userID, err)

// Better: More specific context
return fmt.Errorf("userService: failed to update user %d: %w", userID, err)
Enter fullscreen mode Exit fullscreen mode

3. Use Appropriate Error Types

Choose the right error creation method:

  • Simple errors: errors.New()
  • Formatted errors: fmt.Errorf()
  • Wrapped errors: fmt.Errorf() with %w
  • Structured errors: Custom error types

4. Provide Clear Error Messages

Error messages should be:

  • Descriptive: Explain what went wrong
  • Actionable: Suggest what to do
  • Contextual: Include relevant information (IDs, values, etc.)
// Good
return fmt.Errorf("failed to connect to database: connection timeout after 30s")

// Better
return fmt.Errorf("database connection failed: host=%s port=%d timeout=30s: %w",
    host, port, err)
Enter fullscreen mode Exit fullscreen mode

5. Use Sentinel Errors for Expected Conditions

For errors that callers should handle, use sentinel errors:

var ErrNotFound = errors.New("resource not found")

func findResource(id int) (*Resource, error) {
    // ... lookup
    if notFound {
        return nil, ErrNotFound
    }
    return resource, nil
}
Enter fullscreen mode Exit fullscreen mode

6. Document Error Returns

Document what errors your functions return:

// GetUser retrieves a user by ID.
// Returns ErrNotFound if the user doesn't exist.
func GetUser(id int) (*User, error) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example

Here's a complete example demonstrating error handling in a realistic scenario:

package main

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

var (
    ErrUserNotFound = errors.New("user not found")
    ErrInvalidInput = errors.New("invalid input")
)

type User struct {
    ID    int
    Name  string
    Email string
}

type UserService struct {
    // ... dependencies
}

func (s *UserService) GetUser(id int) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("GetUser: %w: id=%d", ErrInvalidInput, id)
    }

    user, err := s.fetchUserFromDB(id)
    if err != nil {
        return nil, fmt.Errorf("GetUser: failed to fetch user %d: %w", id, err)
    }

    if user == nil {
        return nil, fmt.Errorf("GetUser: %w: id=%d", ErrUserNotFound, id)
    }

    return user, nil
}

func (s *UserService) fetchUserFromDB(id int) (*User, error) {
    // Simulate database error
    return nil, fmt.Errorf("database connection failed")
}

func main() {
    service := &UserService{}

    user, err := service.GetUser(123)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            log.Printf("User not found: %v", err)
        } else if errors.Is(err, ErrInvalidInput) {
            log.Printf("Invalid input: %v", err)
        } else {
            log.Printf("Unexpected error: %v", err)
        }
        return
    }

    fmt.Printf("User: %+v\n", user)
}
Enter fullscreen mode Exit fullscreen mode

Summary

Go's error handling is built on simple principles:

  • Errors are values - returned explicitly from functions
  • Check errors explicitly - no hidden control flow
  • Add context - wrap errors with meaningful information
  • Use appropriate types - simple errors, sentinel errors, or custom types
  • Reserve panic for exceptional cases - use error returns for normal conditions

Key Takeaways:

  1. Always check errors - never ignore them
  2. Use fmt.Errorf() with %w to wrap errors and add context
  3. Use errors.Is() to check for sentinel errors
  4. Use errors.As() to extract custom error types
  5. Create custom error types for structured error information
  6. Use panic only for truly exceptional situations
  7. Provide clear, actionable error messages

Mastering error handling in Go is essential for writing robust, maintainable code. The explicit nature of Go's error handling makes it easier to reason about error flows and ensures that errors are handled appropriately throughout your application.

References

Top comments (0)