← Back to DevBytes

Error Handling Patterns in Go

Error Handling in Go: A Comprehensive Guide

Error handling in Go is fundamentally different from exception-based models found in languages like Java, Python, or JavaScript. Instead of throwing and catching exceptions, Go treats errors as ordinary values that are returned alongside the function's result. This explicit, value-based approach forces developers to confront failure cases at every step, leading to more robust and predictable software.

What Makes Go's Error Handling Different

In Go, functions that can fail return an error interface as their last return value. The calling code must explicitly check this error before proceeding. There are no try-catch blocks, no throw statements, and no hidden control flow jumps. The error interface itself is remarkably simple:

type error interface {
    Error() string
}

Any type that implements an Error() string method satisfies this interface. This minimal design gives developers enormous flexibility in how they represent and handle failures. Here's the most basic example of Go's error handling pattern in action:

package main

import (
    "fmt"
    "os"
)

func main() {
    file, err := os.Open("config.txt")
    if err != nil {
        fmt.Printf("failed to open file: %v\n", err)
        return
    }
    defer file.Close()
    
    // Proceed with file operations...
    fmt.Println("File opened successfully")
}

Why Explicit Error Handling Matters

The Go philosophy treats errors as part of a function's contract, not as exceptional circumstances. This approach brings several concrete benefits:

The Fundamental Pattern: if err != nil

The bread-and-butter of Go error handling is the if err != nil check. While this pattern appears frequently, it forms the backbone of reliable Go programs. Every function that calls a fallible operation should immediately check for errors before using the result:

func processUser(id string) (*User, error) {
    // Validate input early
    if id == "" {
        return nil, fmt.Errorf("user ID cannot be empty")
    }
    
    user, err := fetchFromDatabase(id)
    if err != nil {
        return nil, fmt.Errorf("fetching user %s: %w", id, err)
    }
    
    permissions, err := loadPermissions(user)
    if err != nil {
        return nil, fmt.Errorf("loading permissions for %s: %w", user.Email, err)
    }
    
    user.Permissions = permissions
    return user, nil
}

Notice how each error is returned immediately with added context. This "fail fast" approach prevents cascading failures and keeps the error trace close to its origin.

Sentinel Errors: Predefined Error Values

Sentinel errors are package-level error variables that represent specific failure states. They allow callers to check for particular error conditions using errors.Is. Common examples include io.EOF, sql.ErrNoRows, and context.DeadlineExceeded.

package store

import (
    "errors"
    "fmt"
)

// Sentinel errors for common failure modes
var (
    ErrNotFound     = errors.New("resource not found")
    ErrConflict     = errors.New("resource already exists")
    ErrUnauthorized = errors.New("unauthorized access")
)

func GetRecord(id string) (*Record, error) {
    // Simulated database lookup
    if id == "" {
        return nil, ErrNotFound
    }
    // ... fetch logic
    return nil, fmt.Errorf("unexpected database error")
}

// Caller checks for the sentinel error
func handleRequest(id string) error {
    record, err := GetRecord(id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            return fmt.Errorf("handle request: record missing: %w", err)
        }
        return fmt.Errorf("handle request: %w", err)
    }
    
    fmt.Printf("Found record: %v\n", record)
    return nil
}

The errors.Is function walks the error chain, checking both the top-level error and any wrapped causes. This makes sentinel errors robust even when additional context is wrapped around them.

Custom Error Types for Rich Context

When a simple string message isn't enough, define a custom error type with additional fields. This pattern is especially useful for HTTP handlers, validation logic, or any scenario where the error carries structured data the caller needs:

package main

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

// ValidationError carries structured details about field-level failures
type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
    Code    int
}

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

func validateAge(ageStr string) (int, error) {
    age, err := strconv.Atoi(ageStr)
    if err != nil {
        return 0, &ValidationError{
            Field:   "age",
            Value:   ageStr,
            Message: "must be a valid integer",
            Code:    1001,
        }
    }
    if age < 0 || age > 150 {
        return 0, &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "must be between 0 and 150",
            Code:    1002,
        }
    }
    return age, nil
}

// Caller extracts the custom error type using errors.As
func handleAgeInput(input string) {
    age, err := validateAge(input)
    if err != nil {
        var valErr *ValidationError
        if errors.As(err, &valErr) {
            fmt.Printf("Field '%s' failed validation: %s (code %d)\n",
                valErr.Field, valErr.Message, valErr.Code)
            if valErr.Code == 1001 {
                fmt.Println("Tip: Enter a numeric value like 25")
            }
            return
        }
        fmt.Printf("unexpected error: %v\n", err)
        return
    }
    fmt.Printf("Valid age: %d\n", age)
}

func main() {
    handleAgeInput("twenty")
    handleAgeInput("200")
    handleAgeInput("35")
}

The errors.As function performs type-safe error extraction, traversing the error chain to find the first match. This works seamlessly with wrapped errors, allowing intermediate layers to add context without obscuring the underlying type.

Error Wrapping: Preserving the Causal Chain

Go 1.13 introduced error wrapping via fmt.Errorf with the %w verb. Wrapping attaches the original error as a cause, enabling later inspection with errors.Is and errors.As while adding contextual information:

package main

import (
    "database/sql"
    "errors"
    "fmt"
)

// Simulated database errors
var ErrDBConnection = errors.New("database connection refused")

func connectDB() error {
    return ErrDBConnection
}

func fetchUser(id string) error {
    err := connectDB()
    if err != nil {
        return fmt.Errorf("fetchUser: connecting to database: %w", err)
    }
    // ... query logic
    return nil
}

func getUserProfile(id string) error {
    err := fetchUser(id)
    if err != nil {
        return fmt.Errorf("getUserProfile: fetching user %s: %w", id, err)
    }
    // ... profile logic
    return nil
}

func main() {
    err := getUserProfile("user-123")
    if err != nil {
        // Full error message includes all context
        fmt.Println("Error:", err)
        
        // Check if the root cause is the connection error
        if errors.Is(err, ErrDBConnection) {
            fmt.Println("Root cause: database connection failed")
        }
        
        // Unwrap to inspect each layer
        fmt.Println("\nUnwrapping layers:")
        current := err
        for current != nil {
            fmt.Printf("  - %v\n", current)
            current = errors.Unwrap(current)
        }
    }
}

Output from this program demonstrates the layered error messages:

Error: getUserProfile: fetching user user-123: fetchUser: connecting to database: database connection refused
Root cause: database connection failed

Unwrapping layers:
  - getUserProfile: fetching user user-123: fetchUser: connecting to database: database connection refused
  - fetchUser: connecting to database: database connection refused
  - database connection refused

The %w verb must only be used once per format string. For multiple wrapped errors, use errors.Join (introduced in Go 1.20) instead.

Combining Multiple Errors with errors.Join

Some operations, such as validating multiple fields or cleaning up resources, can produce several independent errors. Rather than losing information by returning only the first failure, use errors.Join to combine them into a single error value:

package main

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

func validateForm(form map[string]string) error {
    var errs []error
    
    if form["email"] == "" {
        errs = append(errs, errors.New("email is required"))
    }
    if form["password"] == "" {
        errs = append(errs, errors.New("password is required"))
    }
    if age := form["age"]; age != "" {
        // Additional validation could go here
        if len(age) > 3 {
            errs = append(errs, errors.New("age looks suspicious"))
        }
    }
    
    if len(errs) > 0 {
        return errors.Join(errs...)
    }
    return nil
}

func main() {
    form := map[string]string{
        "email": "",
        "age":   "9999",
    }
    
    err := validateForm(form)
    if err != nil {
        fmt.Println("Validation failed:")
        // errors.Join creates an error that contains all sub-errors
        // You can inspect individual errors with errors.Is or errors.As
        fmt.Println(err)
        
        // The joined error's Error() output separates each with newlines
        // You can also use errors.Unwrap which returns nil for joined errors
        // Use a type assertion or custom logic for full inspection
    }
    
    // Example with resource cleanup
    err = cleanupResources()
    if err != nil {
        fmt.Println("Cleanup encountered issues:", err)
    }
}

func cleanupResources() error {
    var errs []error
    
    // Attempt to close multiple resources, collecting all failures
    if e := closeFile("/tmp/cache"); e != nil {
        errs = append(errs, fmt.Errorf("closing cache file: %w", e))
    }
    if e := closeConnection("db-connection"); e != nil {
        errs = append(errs, fmt.Errorf("closing db connection: %w", e))
    }
    if e := flushMetrics(); e != nil {
        errs = append(errs, fmt.Errorf("flushing metrics: %w", e))
    }
    
    return errors.Join(errs...)
}

// Stub functions for the cleanup example
func closeFile(path string) error {
    return errors.New("permission denied")
}

func closeConnection(id string) error {
    return nil // this one succeeds
}

func flushMetrics() error {
    return errors.New("metrics buffer full")
}

When printing a joined error, Go concatenates the messages with newlines. To check for a specific error within a joined set, iterate with errors.Unwrap or use type assertions, since errors.Is and errors.As also scan through joined errors.

Panic and Recover: The Escape Hatch

Go also provides panic and recover for truly unrecoverable situations. These should be reserved for programming errors that indicate a bug (like out-of-bounds slice access, nil pointer dereference, or violated invariants) rather than expected failure modes:

package main

import (
    "fmt"
    "log"
)

// Safe division that panics on programmer error
func mustDivide(a, b int) int {
    if b == 0 {
        panic("division by zero is a programming error")
    }
    return a / b
}

// Recovering in a well-defined boundary
func safeCall(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
            log.Printf("recovered from panic: %v\n", r)
        }
    }()
    
    fn()
    return nil
}

func riskyOperation() {
    // This would normally crash the program
    mustDivide(10, 0)
}

func main() {
    // Example 1: Direct panic (comment out to see recovery)
    // mustDivide(10, 0) // This would terminate the program
    
    // Example 2: Using recover within a goroutine boundary
    err := safeCall(func() {
        riskyOperation()
    })
    
    if err != nil {
        fmt.Println("Recovered successfully:", err)
    }
    
    // Example 3: HTTP servers often recover in middleware
    // net/http automatically recovers from panics in handlers
    fmt.Println("Program continues normally after recovery")
}

Critical rule: Never use panic for expected error conditions. Reserve it for truly unrecoverable situations: corrupted internal state, violated invariants, or startup failures where continuing would be dangerous. Libraries should almost never panic; let the caller decide how to handle failures.

Error Handling in Concurrent Code

Goroutines introduce unique error propagation challenges since errors can't be returned across goroutine boundaries directly. Use channels, error groups, or shared error containers to collect errors from concurrent operations:

package main

import (
    "errors"
    "fmt"
    "sync"
)

// Pattern 1: Error channel for goroutine results
func fetchURLs(urls []string) error {
    errCh := make(chan error, len(urls))
    var wg sync.WaitGroup
    
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            if err := processURL(u); err != nil {
                errCh <- fmt.Errorf("processing %s: %w", u, err)
            }
        }(url)
    }
    
    // Wait for all goroutines and close the error channel
    go func() {
        wg.Wait()
        close(errCh)
    }()
    
    // Collect all errors
    var errs []error
    for err := range errCh {
        errs = append(errs, err)
    }
    
    return errors.Join(errs...)
}

// Pattern 2: Shared error container with mutex
func fetchWithSharedError(urls []string) error {
    var (
        mu   sync.Mutex
        errs []error
        wg   sync.WaitGroup
    )
    
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            if err := processURL(u); err != nil {
                mu.Lock()
                errs = append(errs, fmt.Errorf("processing %s: %w", u, err))
                mu.Unlock()
            }
        }(url)
    }
    
    wg.Wait()
    return errors.Join(errs...)
}

// Stub for demonstration
func processURL(url string) error {
    if url == "" {
        return errors.New("empty URL")
    }
    return nil
}

func main() {
    urls := []string{"https://example.com", "", "https://golang.org", ""}
    
    err := fetchURLs(urls)
    if err != nil {
        fmt.Println("Fetch errors:")
        fmt.Println(err)
    } else {
        fmt.Println("All URLs processed successfully")
    }
}

For production code, consider using the golang.org/x/sync/errgroup package, which provides goroutine coordination with built-in error propagation and context cancellation on first error.

Best Practices Summary

Conclusion

Go's error handling model is deliberately simple yet remarkably powerful. By treating errors as values and enforcing explicit checks at every step, Go programs naturally resist the "out of sight, out of mind" problem that plagues exception-heavy codebases. The combination of sentinel errors, custom error types, error wrapping with %w, and the errors.Is / errors.As inspection functions gives developers a complete toolkit for building transparent, debuggable failure paths. Mastering these patterns transforms error handling from a chore into a first-class design concern — one that directly improves the reliability and maintainability of every Go program you write.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles