Navigation

Go

Go Error Handling Patterns: When to Use Error vs Panic in 2025

Master Go error handling with comprehensive patterns for using error vs panic. Learn best practices, real examples, and when to choose each approach for robust Go applications in 2025.

Table Of Contents

Introduction

Error handling is one of the most crucial aspects of writing reliable Go applications. Unlike many programming languages that rely heavily on exceptions, Go takes a more explicit approach with two primary mechanisms: the error interface and the panic function. Understanding when and how to use each approach can make the difference between a robust, maintainable application and one that's prone to unexpected crashes.

In this comprehensive guide, we'll explore the fundamental differences between errors and panics in Go, examine real-world scenarios where each is appropriate, and provide practical patterns you can implement immediately in your projects. Whether you're a beginner learning Go's error handling philosophy or an experienced developer looking to refine your approach, this article will help you make informed decisions about error management.

By the end of this article, you'll have a clear understanding of Go's error handling best practices and be able to implement robust error management strategies that improve your application's reliability and user experience.

Understanding Go's Error Philosophy

Go's approach to error handling is fundamentally different from languages that rely on exceptions. The language designers deliberately chose explicit error handling to make error conditions visible and force developers to think about potential failure points.

The Error Interface

The error interface is Go's idiomatic way of representing error conditions:

type error interface {
    Error() string
}

This simple interface allows any type that implements an Error() method to be used as an error. This design promotes explicit error handling and makes error conditions part of a function's contract.

When to Use Errors

Errors should be used for expected failure conditions that your program can reasonably handle or report to the user. These include:

  • Network timeouts or connection failures
  • File not found or permission denied
  • Invalid user input or validation failures
  • Database connection errors
  • API rate limiting or authentication failures

Here's a practical example of proper error handling:

package main

import (
    "fmt"
    "os"
)

func readFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, fmt.Errorf("failed to read file %s: %w", filename, err)
    }
    return data, nil
}

func main() {
    data, err := readFile("config.json")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        // Handle the error gracefully - maybe use default config
        return
    }
    
    fmt.Printf("File content: %s\n", data)
}

Understanding Panic in Go

Panic is Go's mechanism for handling exceptional situations that represent programming errors or conditions that the program cannot reasonably recover from. When a panic occurs, it stops the normal execution of the current goroutine and begins unwinding the stack.

When to Use Panic

Panics should be reserved for situations where:

  • Programming errors that indicate bugs in the code
  • Invariant violations that should never happen
  • Initialization failures that prevent the program from functioning
  • Resource exhaustion that makes continuation impossible

Here's an example of appropriate panic usage:

package main

import (
    "fmt"
    "regexp"
)

// initializeRegex compiles a regex pattern that should always be valid
func initializeRegex() *regexp.Regexp {
    // This pattern should always compile - if it doesn't, it's a programming error
    pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
    regex, err := regexp.Compile(pattern)
    if err != nil {
        panic(fmt.Sprintf("failed to compile email regex: %v", err))
    }
    return regex
}

func validateSliceIndex(slice []int, index int) int {
    if index < 0 || index >= len(slice) {
        panic(fmt.Sprintf("index %d out of bounds for slice of length %d", index, len(slice)))
    }
    return slice[index]
}

Error Handling Patterns and Best Practices

Pattern 1: Error Wrapping and Context

Go 1.13 introduced error wrapping, which allows you to add context while preserving the original error:

package main

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

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

func fetchUser(userID string) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("/api/users/%s", userID))
    if err != nil {
        return nil, fmt.Errorf("failed to fetch user %s: %w", userID, err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode == 404 {
        return nil, fmt.Errorf("user %s: %w", userID, ErrUserNotFound)
    }
    
    // Parse response...
    return &User{ID: userID}, nil
}

func main() {
    user, err := fetchUser("123")
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            fmt.Println("User doesn't exist - creating new user")
            // Handle specific error case
        } else {
            fmt.Printf("Unexpected error: %v\n", err)
        }
    }
}

Pattern 2: Custom Error Types

For complex applications, custom error types provide more structure and information:

package main

import (
    "fmt"
    "net/http"
)

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 APIError struct {
    StatusCode int
    Message    string
    Cause      error
}

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

func (e *APIError) Unwrap() error {
    return e.Cause
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "age cannot be negative",
        }
    }
    if age > 150 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "age seems unrealistic",
        }
    }
    return nil
}

Panic Patterns and Recovery

Pattern 1: Controlled Panic Recovery

Sometimes you need to recover from panics in specific contexts, such as HTTP handlers:

package main

import (
    "fmt"
    "log"
    "net/http"
)

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic recovered: %v", err)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        
        next.ServeHTTP(w, r)
    })
}

func riskyHandler(w http.ResponseWriter, r *http.Request) {
    // This might panic
    data := []int{1, 2, 3}
    index := 10 // This will cause a panic
    
    // In real code, you'd want to validate this properly
    result := data[index]
    fmt.Fprintf(w, "Result: %d", result)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/risky", riskyHandler)
    
    // Wrap with recovery middleware
    handler := recoverMiddleware(mux)
    
    log.Fatal(http.ListenAndServe(":8080", handler))
}

Pattern 2: Graceful Initialization with Panic

For initialization code, panics can be appropriate when the program cannot continue:

package main

import (
    "database/sql"
    "fmt"
    "log"
    
    _ "github.com/lib/pq"
)

type Config struct {
    DatabaseURL string
    Port        int
}

func mustLoadConfig() *Config {
    // In real code, this would load from environment variables or config files
    config := &Config{
        DatabaseURL: "postgres://user:pass@localhost/db",
        Port:        8080,
    }
    
    if config.DatabaseURL == "" {
        panic("DATABASE_URL environment variable is required")
    }
    
    if config.Port == 0 {
        panic("PORT configuration is required")
    }
    
    return config
}

func mustConnectDB(config *Config) *sql.DB {
    db, err := sql.Open("postgres", config.DatabaseURL)
    if err != nil {
        panic(fmt.Sprintf("failed to open database connection: %v", err))
    }
    
    if err := db.Ping(); err != nil {
        panic(fmt.Sprintf("failed to ping database: %v", err))
    }
    
    return db
}

func main() {
    // These functions panic on critical failures during startup
    // This is acceptable because the program cannot function without these resources
    config := mustLoadConfig()
    db := mustConnectDB(config)
    defer db.Close()
    
    log.Printf("Server starting on port %d", config.Port)
    // Start server...
}

Advanced Error Handling Techniques

Error Aggregation

When dealing with multiple potential errors, you might want to collect and return them together:

package main

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

type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    if len(m.Errors) == 0 {
        return ""
    }
    
    var messages []string
    for _, err := range m.Errors {
        messages = append(messages, err.Error())
    }
    
    return fmt.Sprintf("multiple errors occurred: %s", strings.Join(messages, "; "))
}

func (m *MultiError) Add(err error) {
    if err != nil {
        m.Errors = append(m.Errors, err)
    }
}

func (m *MultiError) HasErrors() bool {
    return len(m.Errors) > 0
}

func validateUser(user *User) error {
    var multiErr MultiError
    
    if user.Name == "" {
        multiErr.Add(errors.New("name is required"))
    }
    
    if user.Email == "" {
        multiErr.Add(errors.New("email is required"))
    } else if !isValidEmail(user.Email) {
        multiErr.Add(errors.New("email format is invalid"))
    }
    
    if user.Age < 0 {
        multiErr.Add(errors.New("age cannot be negative"))
    }
    
    if multiErr.HasErrors() {
        return &multiErr
    }
    
    return nil
}

Retry Patterns with Exponential Backoff

For transient errors, implementing retry logic with exponential backoff can improve reliability:

package main

import (
    "context"
    "fmt"
    "math"
    "math/rand"
    "time"
)

type RetryConfig struct {
    MaxAttempts int
    BaseDelay   time.Duration
    MaxDelay    time.Duration
    Multiplier  float64
}

func WithRetry(ctx context.Context, config RetryConfig, fn func() error) error {
    var lastErr error
    
    for attempt := 0; attempt < config.MaxAttempts; attempt++ {
        if attempt > 0 {
            delay := time.Duration(float64(config.BaseDelay) * math.Pow(config.Multiplier, float64(attempt-1)))
            if delay > config.MaxDelay {
                delay = config.MaxDelay
            }
            
            // Add jitter to prevent thundering herd
            jitter := time.Duration(rand.Float64() * float64(delay) * 0.1)
            delay += jitter
            
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(delay):
            }
        }
        
        if err := fn(); err != nil {
            lastErr = err
            continue
        }
        
        return nil // Success
    }
    
    return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, lastErr)
}

// Example usage
func unreliableOperation() error {
    // Simulate a 70% failure rate
    if rand.Float64() < 0.7 {
        return errors.New("temporary failure")
    }
    return nil
}

func main() {
    ctx := context.Background()
    config := RetryConfig{
        MaxAttempts: 5,
        BaseDelay:   100 * time.Millisecond,
        MaxDelay:    5 * time.Second,
        Multiplier:  2.0,
    }
    
    err := WithRetry(ctx, config, unreliableOperation)
    if err != nil {
        fmt.Printf("Operation failed: %v\n", err)
    } else {
        fmt.Println("Operation succeeded")
    }
}

Performance Considerations

Error Allocation Optimization

Creating new error values can have performance implications in high-throughput applications:

package main

import (
    "errors"
    "fmt"
)

// Pre-define common errors to avoid allocations
var (
    ErrInvalidInput    = errors.New("invalid input")
    ErrNotFound        = errors.New("not found")
    ErrPermissionDenied = errors.New("permission denied")
)

// Use error variables instead of creating new errors each time
func fastValidation(input string) error {
    if input == "" {
        return ErrInvalidInput // Reuse pre-allocated error
    }
    
    // Only create new errors when you need specific context
    if len(input) > 100 {
        return fmt.Errorf("input too long: %d characters (max 100)", len(input))
    }
    
    return nil
}

// For frequently called functions, consider using bool returns for simple cases
func isValid(input string) bool {
    return input != "" && len(input) <= 100
}

Common Pitfalls and Anti-Patterns

Pitfall 1: Ignoring Errors

Never ignore errors without explicit justification:

// Bad: Ignoring errors
func badExample() {
    data, _ := ioutil.ReadFile("config.json") // What if this fails?
    // Using data without checking if read succeeded
}

// Good: Handle or explicitly ignore with comment
func goodExample() {
    data, err := ioutil.ReadFile("config.json")
    if err != nil {
        log.Printf("Failed to read config: %v, using defaults", err)
        data = []byte(`{"default": true}`)
    }
    // Now safely use data
}

Pitfall 2: Panic in Library Code

Libraries should generally return errors rather than panic:

// Bad: Library function that panics
func badLibraryFunction(input string) string {
    if input == "" {
        panic("input cannot be empty")
    }
    return strings.ToUpper(input)
}

// Good: Library function that returns error
func goodLibraryFunction(input string) (string, error) {
    if input == "" {
        return "", errors.New("input cannot be empty")
    }
    return strings.ToUpper(input), nil
}

Testing Error Conditions

Proper error handling includes testing error scenarios:

package main

import (
    "errors"
    "testing"
)

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

func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"valid division", 10, 2, 5, false},
        {"division by zero", 10, 0, 0, true},
        {"negative numbers", -10, 2, -5, false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := divide(tt.a, tt.b)
            if (err != nil) != tt.wantErr {
                t.Errorf("divide() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("divide() = %v, want %v", got, tt.want)
            }
        })
    }
}

FAQ

Q: When should I use panic instead of returning an error? A: Use panic only for programming errors or situations where the program cannot continue. Examples include invalid array indices that should never happen, failed assertions, or critical initialization failures. For all expected error conditions like network failures or invalid user input, use the error interface.

Q: Is it okay to recover from all panics? A: No, you should only recover from panics in specific, controlled contexts like HTTP handlers or worker goroutines. Recovering from all panics can hide serious bugs and make debugging difficult. Let panics crash the program during development to identify issues early.

Q: How do I handle errors in goroutines? A: Goroutines can't return errors directly. Use channels to communicate errors back to the main goroutine, or implement error handling within the goroutine itself. Consider using the errgroup package for managing multiple goroutines with error handling.

Q: Should I wrap all errors with additional context? A: Wrap errors when you can add meaningful context about what operation was being performed. Don't wrap errors just to wrap them - only add context that helps with debugging or user understanding. Use fmt.Errorf with %w verb to preserve the original error for unwrapping.

Q: What's the difference between errors.Is and errors.As? A: errors.Is checks if an error matches a specific error value (including wrapped errors). errors.As extracts an error of a specific type from the error chain. Use Is for sentinel errors and As for custom error types.

Q: How do I create custom error types effectively? A: Implement the error interface and optionally the Unwrap() method if your error wraps another error. Include relevant fields that provide context about the error condition. Consider implementing Is() and As() methods for custom matching behavior.

Conclusion

Mastering Go's error handling patterns is essential for building robust, maintainable applications. The key takeaways from this guide are:

  • Use errors for expected failure conditions that your program can handle gracefully
  • Reserve panics for programming errors and situations where the program cannot continue
  • Implement proper error wrapping to provide context while preserving original errors
  • Create custom error types when you need structured error information
  • Use recovery sparingly and only in controlled contexts like middleware

Remember that good error handling is not just about preventing crashes - it's about providing clear feedback to users, enabling effective debugging, and building resilient systems that can handle unexpected conditions gracefully.

By following these patterns and best practices, you'll write Go code that is more reliable, easier to debug, and provides better user experiences. Start implementing these techniques in your next Go project and watch your application's robustness improve significantly.

Ready to improve your Go error handling? Start by auditing your current codebase for proper error handling patterns, and gradually implement the techniques discussed in this article. Share your experiences and questions in the comments below!

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Go