📚 Theory: Go's Error Philosophy
Go takes a unique approach to error handling that emphasizes explicit error checking over exceptions. This design choice makes error paths clear and encourages developers to handle errors where they occur.
Why Explicit Error Handling?
Go's designers chose explicit error values over exceptions for several reasons:
- Clarity: Error handling is visible in the code flow
- Control: Developers decide how to handle each error
- Performance: No exception unwinding overhead
- Simplicity: Errors are just values that can be passed around
The Error Interface
// The error interface is built into Go type error interface { Error() string }
Error Handling Patterns
Pattern | Use Case | Example |
---|---|---|
Simple Check | Basic error checking | if err != nil { return err } |
Wrap & Return | Add context to errors | return fmt.Errorf("context: %w", err) |
Handle & Continue | Non-fatal errors | if err != nil { log.Warn(err) } |
Sentinel Check | Check specific errors | if errors.Is(err, ErrNotFound) |
Type Assertion | Extract error details | var e *CustomErr; errors.As(err, &e) |
Basic Error Creation and Handling
package main import ( "errors" "fmt" "os" "log" ) // Basic error creation func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil } // Error wrapping with context (Go 1.13+) func readFile(filename string) ([]byte, error) { data, err := os.ReadFile(filename) if err != nil { // Wrap error with %w verb for error chain return nil, fmt.Errorf("failed to read file %s: %w", filename, err) } return data, nil } func main() { // Always check errors immediately result, err := divide(10, 0) if err != nil { fmt.Printf("Error: %v\n", err) return } fmt.Printf("Result: %.2f\n", result) // Early return pattern - fail fast data, err := readFile("config.json") if err != nil { log.Fatal(err) // Exits program with error } fmt.Printf("Read %d bytes\n", len(data)) }
🎨 Custom Error Types
Custom error types provide rich context, structured data, and type-safe error handling. They're essential for building maintainable applications.
Struct Errors
Use structs to carry additional error context and metadata.
- Field-level validation errors
- HTTP status codes
- Error timestamps
- Request IDs for tracing
Sentinel Errors
Predefined error variables for common error conditions.
- io.EOF for end of file
- sql.ErrNoRows for no results
- Custom domain errors
- Use with errors.Is()
Error Wrapping
Preserve error context through the call stack.
- Add context with fmt.Errorf
- Maintain error chain
- Use %w for wrapping
- Unwrap with errors.Unwrap
// Custom error with additional context type ValidationError struct { Field string Value interface{} Message string } func (e ValidationError) Error() string { return fmt.Sprintf("validation error on field '%s': %s (got: %v)", e.Field, e.Message, e.Value) } // Error with error code and wrapping support type AppError struct { Code string Message string Err error } func (e AppError) Error() string { if e.Err != nil { return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err) } return fmt.Sprintf("[%s] %s", e.Code, e.Message) } func (e AppError) Unwrap() error { return e.Err } // Sentinel errors for common conditions var ( ErrNotFound = errors.New("resource not found") ErrUnauthorized = errors.New("unauthorized access") ErrInvalidInput = errors.New("invalid input") ErrRateLimited = errors.New("rate limit exceeded") )
⛓️ Error Chains and Context
Go 1.13 introduced error wrapping, allowing errors to maintain context as they propagate up the call stack.
Error Chain Visualization
import ( "errors" "fmt" ) // Wrapping errors through layers func dbQuery(id string) error { return ErrNotFound // Original error } func getUser(id string) error { err := dbQuery(id) if err != nil { return fmt.Errorf("getUser %s: %w", id, err) // Wrap } return nil } func handleRequest(id string) error { err := getUser(id) if err != nil { return fmt.Errorf("request failed: %w", err) // Wrap again } return nil } // Checking and unwrapping errors func main() { err := handleRequest("123") // Check if error chain contains ErrNotFound if errors.Is(err, ErrNotFound) { fmt.Println("User not found") } // Extract specific error type from chain var appErr *AppError if errors.As(err, &appErr) { fmt.Printf("App error code: %s\n", appErr.Code) } // Walk the error chain for err != nil { fmt.Printf("Error: %v\n", err) err = errors.Unwrap(err) } }
🔄 Error Handling Patterns
Common patterns for handling errors effectively in production Go applications.
// Retry pattern with exponential backoff func retryWithBackoff(fn func() error, maxRetries int) error { var err error backoff := time.Second for i := 0; i < maxRetries; i++ { if err = fn(); err == nil { return nil } if i < maxRetries-1 { time.Sleep(backoff) backoff *= 2 } } return fmt.Errorf("failed after %d retries: %w", maxRetries, err) } // Error aggregation for batch operations type MultiError struct { Errors []error } func (m *MultiError) Add(err error) { if err != nil { m.Errors = append(m.Errors, err) } } func (m MultiError) Error() string { if len(m.Errors) == 0 { return "no errors" } var msgs []string for _, err := range m.Errors { msgs = append(msgs, err.Error()) } return strings.Join(msgs, "; ") } // Cleanup with defer and error capture func processFile(filename string) (err error) { f, err := os.Open(filename) if err != nil { return err } // Ensure file is closed, capture any close error defer func() { if closeErr := f.Close(); closeErr != nil && err == nil { err = closeErr } }() // Process file... return nil } // Panic recovery for critical sections func safeOperation() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic recovered: %v", r) } }() // Operation that might panic riskyOperation() return nil }
📊 Structured Error Logging
Enhance error visibility with structured logging and contextual information.
import ( "log/slog" "context" ) // Error with structured logging support type LoggableError struct { Op string Kind string Err error Details map[string]interface{} } func (e LoggableError) Error() string { return fmt.Sprintf("%s: %s: %v", e.Op, e.Kind, e.Err) } func (e LoggableError) LogValue() slog.Value { attrs := []slog.Attr{ slog.String("op", e.Op), slog.String("kind", e.Kind), } for k, v := range e.Details { attrs = append(attrs, slog.Any(k, v)) } if e.Err != nil { attrs = append(attrs, slog.String("error", e.Err.Error())) } return slog.GroupValue(attrs...) } // Context-aware error handling func processWithContext(ctx context.Context, id string) error { logger := slog.Default().With("request_id", id) data, err := fetchData(ctx, id) if err != nil { logger.Error("failed to fetch data", "error", err, "id", id, ) return LoggableError{ Op: "processWithContext", Kind: "fetch", Err: err, Details: map[string]interface{}{ "id": id, }, } } return nil }
⚡ Performance and Best Practices
Error Handling Performance
Approach | Performance | Memory | Use Case |
---|---|---|---|
errors.New() | Fast | Low | Static error messages |
fmt.Errorf() | Slower | Medium | Dynamic error messages |
Custom Types | Fast | Variable | Rich error context |
Panic/Recover | Very Slow | High | Exceptional cases only |
Error Handling Best Practices
- Fail fast: Return errors immediately when detected
- Add context: Wrap errors with meaningful messages
- Log once: Avoid logging the same error multiple times
- Handle or return: Don't do both - choose one
- Use error types: Create custom types for domain errors
- Document errors: Specify which errors functions can return
- Test error paths: Write tests for error conditions
Common Anti-Patterns to Avoid
- Ignoring errors: Never use
_ = someFunc()
- Generic errors: Avoid
errors.New("error")
- String comparison: Don't compare
err.Error() == "text"
- Panic for flow control: Reserve panic for truly exceptional cases
- Nested error handling: Keep error handling flat and simple
🏋️ Practice Exercises
Challenge 1: Retry with Backoff
Implement a retry mechanism with exponential backoff for network operations:
- Retry failed operations up to N times
- Implement exponential backoff between retries
- Support context cancellation
- Return all errors if max retries exceeded
Challenge 2: Error Aggregator
Create an error aggregator for batch operations:
- Collect multiple errors from concurrent operations
- Implement custom Error() method
- Support error categorization
- Provide detailed error reporting
Challenge 3: Circuit Breaker
Build a circuit breaker pattern for fault tolerance:
- Track error rates over time
- Open circuit on threshold breach
- Implement half-open state for recovery
- Support custom error predicates
Challenge 4: Error Reporter
Design an error reporting system:
- Capture error context and stack traces
- Group similar errors together
- Support error severity levels
- Implement rate limiting for error reports