Skip to main content
โšก Calmops

Error Handling in Go: Patterns, Wrapping, and Best Practices

Introduction

Go’s approach to error handling is explicit and deliberate โ€” functions return errors as values, and callers must handle them. This design makes error paths visible in code, unlike exceptions which can be invisible. This guide covers Go’s error handling patterns from basics to production-grade techniques.

The Basics: Error as a Return Value

// Functions that can fail return (result, error)
data, err := os.ReadFile("config.json")
if err != nil {
    log.Fatal("failed to read config:", err)
}

The error interface is simple:

type error interface {
    Error() string
}

Any type implementing Error() string satisfies the interface.

Creating Errors

import "errors"
import "fmt"

// Simple string error
err := errors.New("something went wrong")

// Formatted error
err = fmt.Errorf("user %d not found", userID)

// Sentinel errors (package-level variables)
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")

Error Wrapping (Go 1.13+)

Wrap errors to add context while preserving the original for inspection:

func openDatabase(path string) (*sql.DB, error) {
    db, err := sql.Open("postgres", path)
    if err != nil {
        // %w wraps the error โ€” preserves it for errors.Is/As
        return nil, fmt.Errorf("openDatabase: %w", err)
    }
    return db, nil
}

func initApp() error {
    db, err := openDatabase(os.Getenv("DATABASE_URL"))
    if err != nil {
        return fmt.Errorf("initApp: %w", err)
    }
    // ...
    return nil
}

The error chain: initApp: openDatabase: dial tcp: connection refused

errors.Is: Check Error Type in Chain

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

func getUser(id int) (*User, error) {
    user, err := db.FindUser(id)
    if err != nil {
        return nil, fmt.Errorf("getUser(%d): %w", id, ErrNotFound)
    }
    return user, nil
}

// Caller can check for specific error type anywhere in the chain
user, err := getUser(42)
if errors.Is(err, ErrNotFound) {
    // handle not found
}

errors.As: Extract Specific Error Type

type ValidationError struct {
    Field   string
    Message string
}

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

func validateUser(u User) error {
    if u.Email == "" {
        return fmt.Errorf("validateUser: %w", &ValidationError{
            Field:   "email",
            Message: "is required",
        })
    }
    return nil
}

// Extract the ValidationError from the chain
err := validateUser(user)
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Printf("Field %q: %s\n", valErr.Field, valErr.Message)
}

Custom Error Types

// HTTP-aware error type
type APIError struct {
    StatusCode int
    Message    string
    Err        error
}

func (e *APIError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("API error %d: %s: %v", e.StatusCode, e.Message, e.Err)
    }
    return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}

// Implement Unwrap for errors.Is/As to work through the chain
func (e *APIError) Unwrap() error {
    return e.Err
}

// Usage
func fetchUser(id int) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("/api/users/%d", id))
    if err != nil {
        return nil, &APIError{StatusCode: 0, Message: "request failed", Err: err}
    }
    if resp.StatusCode == 404 {
        return nil, &APIError{StatusCode: 404, Message: "user not found"}
    }
    // ...
}

// Caller
user, err := fetchUser(42)
var apiErr *APIError
if errors.As(err, &apiErr) {
    switch apiErr.StatusCode {
    case 404:
        // handle not found
    case 401:
        // handle unauthorized
    default:
        log.Printf("API error: %v", apiErr)
    }
}

Sentinel Errors Pattern

Define package-level error variables for errors callers need to check:

package store

import "errors"

// Sentinel errors โ€” exported for callers to check
var (
    ErrNotFound   = errors.New("not found")
    ErrDuplicate  = errors.New("duplicate entry")
    ErrForbidden  = errors.New("forbidden")
)

func (s *Store) GetUser(id int) (*User, error) {
    var user User
    err := s.db.QueryRow("SELECT * FROM users WHERE id = $1", id).Scan(&user)
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("GetUser(%d): %w", id, ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("GetUser(%d): %w", id, err)
    }
    return &user, nil
}
// Caller
user, err := store.GetUser(42)
if errors.Is(err, store.ErrNotFound) {
    http.Error(w, "User not found", http.StatusNotFound)
    return
}
if err != nil {
    http.Error(w, "Internal error", http.StatusInternalServerError)
    log.Printf("GetUser: %v", err)
    return
}

Panic and Recover

Panic is for unrecoverable errors โ€” programming bugs, not expected failures:

// Use panic for programming errors
func divide(a, b int) int {
    if b == 0 {
        panic("divide by zero") // programming error
    }
    return a / b
}

// Use recover in top-level handlers to prevent crashes
func safeHandler(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic: %v\n%s", err, debug.Stack())
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        h(w, r)
    }
}

Rule: Never use panic for expected errors (file not found, network timeout, invalid input). Use error returns.

Defer for Cleanup

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("processFile: %w", err)
    }
    defer f.Close() // always runs, even if we return early

    // Process...
    return nil
}

// Named return values + defer for error annotation
func writeFile(path string, data []byte) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("writeFile: %w", err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = fmt.Errorf("writeFile close: %w", cerr)
        }
    }()

    _, err = f.Write(data)
    return
}

Error Handling in HTTP Handlers

// Helper to reduce boilerplate
type AppError struct {
    Code    int
    Message string
    Err     error
}

func (e *AppError) Error() string { return e.Message }

type HandlerFunc func(w http.ResponseWriter, r *http.Request) error

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := f(w, r); err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            http.Error(w, appErr.Message, appErr.Code)
        } else {
            log.Printf("unhandled error: %v", err)
            http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        }
    }
}

// Clean handler โ€” just return errors
func getUser(w http.ResponseWriter, r *http.Request) error {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        return &AppError{Code: 400, Message: "invalid user ID"}
    }

    user, err := store.GetUser(id)
    if errors.Is(err, store.ErrNotFound) {
        return &AppError{Code: 404, Message: "user not found"}
    }
    if err != nil {
        return fmt.Errorf("getUser: %w", err)
    }

    return json.NewEncoder(w).Encode(user)
}

// Register
http.Handle("/users/{id}", HandlerFunc(getUser))

Best Practices

// 1. Add context when wrapping
return fmt.Errorf("createOrder(userID=%d): %w", userID, err)

// 2. Don't wrap sentinel errors with extra context that loses the type
// Bad:
return fmt.Errorf("not found: %s", ErrNotFound) // loses wrapping
// Good:
return fmt.Errorf("user %d: %w", id, ErrNotFound)

// 3. Log at the boundary, not at every level
// Bad: log at every level (duplicate logs)
// Good: log once at the top level, propagate errors up

// 4. Don't ignore errors
result, _ := doSomething() // BAD
result, err := doSomething() // GOOD
if err != nil { ... }

// 5. Use errors.Is/As, not type assertions
// Bad:
if err.(*os.PathError) != nil { ... }
// Good:
var pathErr *os.PathError
if errors.As(err, &pathErr) { ... }

Resources

Comments