The Complete Guide to Error Handling in Go: From Basics to Best Practices
A comprehensive guide to understanding, implementing, and mastering error handling in Go
Table of Contents
- Introduction: Why Error Handling Matters
- Understanding Errors in Go
- Basic Error Handling Patterns
- Creating Custom Errors
- Error Wrapping and Unwrapping (Go 1.13+)
- Sentinel Errors
- Error Types and Type Assertions
- Best Practices
- Common Mistakes to Avoid
- Real-World Examples
- Comparison with Other Languages
- Summary
1. Introduction: Why Error Handling Matters
The Cost of Poor Error Handling
Let me tell you a story. In 1996, the European Space Agency launched the Ariane 5 rocket. Just 37 seconds after launch, it exploded. The reason? A software error. A 64-bit floating-point number was converted to a 16-bit integer, causing an overflow. The error wasn't handled properly, and the rocket's guidance system failed.
Cost: $370 million and years of work, gone in 37 seconds.
This extreme example shows why error handling matters. In your applications:
- Poor error handling can lose customer data
- It can cause financial losses
- It can compromise security
- It can destroy user trust
What is Error Handling?
Error handling is the process of:
- Detecting when something goes wrong
- Communicating what went wrong
- Responding appropriately to the problem
- Recovering (if possible) or failing gracefully
Think of it like this: Imagine you're driving a car.
- No error handling: Car breaks down, no warning lights, you're stranded
- Bad error handling: Warning light blinks randomly, you ignore it
- Good error handling: Specific warning (low oil), you check and fix it before damage
Go's Philosophy: Errors are Values
Most languages use exceptions - special control flow mechanisms that "throw" errors up the call stack until something "catches" them. Go is different.
In Go, errors are just values. Like numbers, strings, or booleans.
go// In Java (exception-based) try { result = riskyOperation(); } catch (Exception e) { handleError(e); } // In Go (error-as-value) result, err := riskyOperation() if err != nil { handleError(err) }
Why does Go do this?
- Explicit: You can see exactly where errors are checked
- Simple: No hidden control flow
- Flexible: Errors are just values you can manipulate
- Clear: Reading code shows the error paths
Let's explore this in depth.
2. Understanding Errors in Go
The Error Interface
At its core, an error in Go is incredibly simple. It's defined by this interface:
gotype error interface { Error() string }
What does this mean?
Any type that has a method called which returns a can be used as an error. That's it!
code
Error()code
stringLet's see the simplest possible error:
gopackage main import "fmt" // Create our own error type type MyError struct { Message string } // Implement the Error() method func (e MyError) Error() string { return e.Message } func main() { var err error = MyError{Message: "something went wrong"} fmt.Println(err) // Prints: something went wrong }
Breaking it down:
- We created a type
with acodeMyError fieldcodeMessage - We gave it an
method that returns a stringcodeError() - Now
satisfies thecodeMyError interfacecodeerror - We can use it anywhere an error is expected
Creating Simple Errors
You don't usually create your own error types from scratch. Go provides convenient functions:
Using codeerrors.New()
code
errors.New() gopackage 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, 2) if err != nil { fmt.Println("Error:", err) return } fmt.Println("Result:", result) // Prints: Result: 5 result, err = divide(10, 0) if err != nil { fmt.Println("Error:", err) // Prints: Error: division by zero return } }
Understanding the pattern:
gofunc someFunction() (resultType, error)
This is the standard Go pattern:
- Functions that can fail return two values
- The last return value is always code
error - If everything is fine, error is code
nil - If something fails, error contains information about the failure
Using codefmt.Errorf()
code
fmt.Errorf()When you need to include dynamic information in your error message:
gopackage main import ( "fmt" ) func withdraw(balance, amount float64) (float64, error) { if amount <= 0 { return balance, fmt.Errorf("invalid withdrawal amount: $%.2f", amount) } if amount > balance { return balance, fmt.Errorf( "insufficient funds: balance=$%.2f, requested=$%.2f", balance, amount, ) } return balance - amount, nil } func main() { balance := 100.0 // Try to withdraw negative amount newBalance, err := withdraw(balance, -50) if err != nil { fmt.Println("Error:", err) // Prints: Error: invalid withdrawal amount: $-50.00 } // Try to withdraw too much newBalance, err = withdraw(balance, 200) if err != nil { fmt.Println("Error:", err) // Prints: Error: insufficient funds: balance=$100.00, requested=$200.00 } // Valid withdrawal newBalance, err = withdraw(balance, 30) if err != nil { fmt.Println("Error:", err) } else { fmt.Printf("Success! New balance: $%.2f\n", newBalance) // Prints: Success! New balance: $70.00 } }
Why use ?
code
fmt.Errorf()- Formats messages with dynamic values
- Makes errors more informative
- Helps with debugging
The Meaning of codenil
code
nilIn Go, represents "nothing" or "no value". For errors:
code
nil govar err error = nil // No error occurred
Visual representation:
codeFunction succeeds: ┌──────────────┐ │ Function │ │ executes │ ──► Returns: (result, nil) │ correctly │ ↑ └──────────────┘ │ No error! Function fails: ┌──────────────┐ │ Function │ │ encounters │ ──► Returns: (zero-value, error) │ problem │ ↑ └──────────────┘ │ Error details
Key concept: Always check after operations that can fail.
code
if err != nil3. Basic Error Handling Patterns
Pattern 1: The Standard Check
This is the pattern you'll see everywhere in Go code:
gopackage main import ( "fmt" "os" ) func main() { // Try to open a file file, err := os.Open("data.txt") if err != nil { fmt.Println("Failed to open file:", err) return } defer file.Close() // If we reach here, file was opened successfully fmt.Println("File opened successfully!") }
Flow visualization:
codeStart ↓ Open file ↓ Error? ──Yes──> Print error ──> Exit ↓ No Continue with file ↓ Close file ↓ End
The pattern:
- Call function that returns code
(result, error) - Immediately check: code
if err != nil - Handle error (often return or exit)
- Continue if no error
Pattern 2: Early Returns (Guard Clauses)
Handle errors early and keep the "happy path" left-aligned:
gopackage main import ( "fmt" "io/ioutil" "os" ) func processFile(filename string) error { // Step 1: Open file file, err := os.Open(filename) if err != nil { return fmt.Errorf("failed to open %s: %w", filename, err) } defer file.Close() // Step 2: Read content content, err := ioutil.ReadAll(file) if err != nil { return fmt.Errorf("failed to read %s: %w", filename, err) } // Step 3: Validate content if len(content) == 0 { return fmt.Errorf("file %s is empty", filename) } // Step 4: Process content fmt.Printf("Processing %d bytes from %s\n", len(content), filename) return nil // Success! } func main() { err := processFile("document.txt") if err != nil { fmt.Println("Error:", err) os.Exit(1) } fmt.Println("File processed successfully!") }
Why this pattern is good:
go// BAD: Nested if statements (harder to read) func processFile(filename string) error { file, err := os.Open(filename) if err == nil { content, err := ioutil.ReadAll(file) if err == nil { if len(content) > 0 { // Process content fmt.Println("Processing...") return nil } else { return errors.New("empty file") } } else { return err } } else { return err } } // GOOD: Early returns (easier to read) func processFile(filename string) error { file, err := os.Open(filename) if err != nil { return err } content, err := ioutil.ReadAll(file) if err != nil { return err } if len(content) == 0 { return errors.New("empty file") } fmt.Println("Processing...") return nil }
The second version is much easier to read because:
- Each error check returns immediately
- The "happy path" (success case) is left-aligned
- No deep nesting
Pattern 3: Handling vs. Returning
Sometimes you handle errors locally, sometimes you pass them up:
gopackage main import ( "fmt" "os" ) // Handle error locally: Try alternative approach func readConfigFile() ([]byte, error) { // Try to read config file data, err := os.ReadFile("config.json") if err != nil { // If file doesn't exist, return default config if os.IsNotExist(err) { fmt.Println("Config not found, using defaults") return []byte(`{"port": 8080}`), nil } // Other errors: pass them up return nil, fmt.Errorf("failed to read config: %w", err) } return data, nil } // Return error: Let caller decide what to do func connectToDatabase(host string) error { // Simulate connection attempt fmt.Printf("Connecting to %s...\n", host) // If connection fails, return error return fmt.Errorf("connection to %s failed: network unreachable", host) } func main() { // Example 1: Function handles some errors internally config, err := readConfigFile() if err != nil { fmt.Println("Fatal:", err) return } fmt.Println("Config:", string(config)) // Example 2: We handle the error from connectToDatabase err = connectToDatabase("localhost:5432") if err != nil { // Decide what to do: maybe try backup server fmt.Println("Primary DB failed:", err) err = connectToDatabase("backup.server:5432") if err != nil { fmt.Println("Backup DB also failed:", err) return } } fmt.Println("Connected successfully!") }
Decision tree for error handling:
codeError occurred ↓ Can I fix it here? ──Yes──> Fix and continue ↓ No ↓ Should I try alternative? ──Yes──> Try alternative ↓ No ↓ Add context and return error
Pattern 4: Checking Multiple Errors
When you need to perform multiple operations:
gopackage main import ( "fmt" "os" ) func copyFile(src, dst string) error { // Step 1: Open source sourceFile, err := os.Open(src) if err != nil { return fmt.Errorf("cannot open source file: %w", err) } defer sourceFile.Close() // Step 2: Create destination destFile, err := os.Create(dst) if err != nil { return fmt.Errorf("cannot create destination file: %w", err) } defer destFile.Close() // Step 3: Copy content _, err = sourceFile.WriteTo(destFile) if err != nil { return fmt.Errorf("failed to copy content: %w", err) } // Step 4: Sync to disk err = destFile.Sync() if err != nil { return fmt.Errorf("failed to sync file: %w", err) } return nil } func main() { err := copyFile("source.txt", "destination.txt") if err != nil { fmt.Println("Copy failed:", err) return } fmt.Println("File copied successfully!") }
Notice the pattern:
goresult, err := operation() if err != nil { return fmt.Errorf("context: %w", err) } // Use result...
This appears after every operation that can fail.
4. Creating Custom Errors
Why Custom Errors?
Sometimes you need more than just a message. You might want:
- Error codes
- Additional context
- Structured data
- Error categories
Method 1: Simple String Formatting
Use for simple cases:
code
fmt.Errorf() gopackage main import "fmt" type User struct { ID int Name string Age int } func validateUser(user User) error { if user.Name == "" { return fmt.Errorf("user %d: name cannot be empty", user.ID) } if user.Age < 0 { return fmt.Errorf("user %d (%s): invalid age %d", user.ID, user.Name, user.Age) } if user.Age < 18 { return fmt.Errorf("user %d (%s): must be 18+, got %d", user.ID, user.Name, user.Age) } return nil } func main() { users := []User{ {ID: 1, Name: "", Age: 25}, {ID: 2, Name: "Alice", Age: -5}, {ID: 3, Name: "Bob", Age: 15}, {ID: 4, Name: "Charlie", Age: 25}, } for _, user := range users { if err := validateUser(user); err != nil { fmt.Println("Validation error:", err) } else { fmt.Printf("User %d (%s) is valid\n", user.ID, user.Name) } } }
Output:
codeValidation error: user 1: name cannot be empty Validation error: user 2 (Alice): invalid age -5 Validation error: user 3 (Bob): must be 18+, got 15 User 4 (Charlie) is valid
Method 2: Custom Error Types
For complex scenarios, create structured error types:
gopackage main import "fmt" // Define custom error type type ValidationError struct { Field string // Which field failed Value interface{} // What value was provided Message string // Why it failed } // Implement the error interface func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed for '%s': %s (got: %v)", e.Field, e.Message, e.Value) } // Validation function 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", } } if age < 18 { return &ValidationError{ Field: "age", Value: age, Message: "must be 18 or older", } } return nil } func main() { testAges := []int{-5, 15, 25, 200} for _, age := range testAges { err := validateAge(age) if err != nil { fmt.Println("Error:", err) // We can also access structured data if validErr, ok := err.(*ValidationError); ok { fmt.Printf(" → Field: %s\n", validErr.Field) fmt.Printf(" → Value: %v\n", validErr.Value) fmt.Printf(" → Message: %s\n", validErr.Message) } fmt.Println() } else { fmt.Printf("Age %d is valid\n\n", age) } } }
Output:
codeError: validation failed for 'age': age cannot be negative (got: -5) → Field: age → Value: -5 → Message: age cannot be negative Error: validation failed for 'age': must be 18 or older (got: 15) → Field: age → Value: 15 → Message: must be 18 or older Age 25 is valid Error: validation failed for 'age': age seems unrealistic (got: 200) → Field: age → Value: 200 → Message: age seems unrealistic
Method 3: Error with Context
Add rich context to errors:
gopackage main import ( "fmt" "time" ) // Error with full context type DatabaseError struct { Operation string // What we were trying to do Table string // Which table Timestamp time.Time // When it happened Original error // The underlying error } func (e *DatabaseError) Error() string { return fmt.Sprintf( "[%s] database error during %s on table '%s': %v", e.Timestamp.Format("2006-01-02 15:04:05"), e.Operation, e.Table, e.Original, ) } // Unwrap allows errors.Is and errors.As to work func (e *DatabaseError) Unwrap() error { return e.Original } // Simulate database operations func insertUser(name string) error { // Simulate an error originalErr := fmt.Errorf("duplicate key violation") return &DatabaseError{ Operation: "INSERT", Table: "users", Timestamp: time.Now(), Original: originalErr, } } func main() { err := insertUser("john@example.com") if err != nil { fmt.Println("Error:", err) // Check if it's our custom error if dbErr, ok := err.(*DatabaseError); ok { fmt.Println("\nDetailed information:") fmt.Printf(" Operation: %s\n", dbErr.Operation) fmt.Printf(" Table: %s\n", dbErr.Table) fmt.Printf(" Time: %s\n", dbErr.Timestamp.Format(time.RFC3339)) fmt.Printf(" Root cause: %v\n", dbErr.Original) } } }
Output:
codeError: [2024-01-15 14:30:45] database error during INSERT on table 'users': duplicate key violation Detailed information: Operation: INSERT Table: users Time: 2024-01-15T14:30:45Z Root cause: duplicate key violation
5. Error Wrapping and Unwrapping (Go 1.13+)
The Problem: Losing Context
Imagine this scenario:
gofunc level1() error { return errors.New("disk full") } func level2() error { err := level1() if err != nil { return err // Just passing it up } return nil } func level3() error { err := level2() if err != nil { return err // Just passing it up } return nil } func main() { err := level3() if err != nil { fmt.Println(err) // Prints: disk full // But WHERE did this error come from? // What operation failed? } }
You get "disk full" but you don't know:
- Which function encountered this?
- What operation was being performed?
- What file or resource was involved?
The Solution: Error Wrapping
Go 1.13 introduced verb for wrapping errors:
code
%w gopackage main import ( "errors" "fmt" ) func readDatabase() error { // Simulate a low-level error return errors.New("connection refused") } func fetchUser(id int) error { err := readDatabase() if err != nil { // Wrap the error with context return fmt.Errorf("fetchUser(id=%d): %w", id, err) } return nil } func handleRequest() error { err := fetchUser(12345) if err != nil { // Wrap again with more context return fmt.Errorf("handleRequest failed: %w", err) } return nil } func main() { err := handleRequest() if err != nil { fmt.Println("Error:", err) // Prints: Error: handleRequest failed: fetchUser(id=12345): connection refused } }
Visual representation of wrapping:
codeOriginal error: "connection refused" ↓ Wrapped: "fetchUser(id=12345): connection refused" ↓ Wrapped again: "handleRequest failed: fetchUser(id=12345): connection refused"
Using errors.Is() to Check Errors
With wrapping, you can check if a specific error is in the chain:
gopackage main import ( "errors" "fmt" ) var ( ErrNotFound = errors.New("not found") ErrUnauthorized = errors.New("unauthorized") ErrTimeout = errors.New("timeout") ) func fetchData(id int) error { // Simulate error return fmt.Errorf("database query failed: %w", ErrNotFound) } func main() { err := fetchData(123) // Check if the error chain contains ErrNotFound if errors.Is(err, ErrNotFound) { fmt.Println("Item not found - maybe create it?") } else if errors.Is(err, ErrTimeout) { fmt.Println("Timeout - retry later") } else { fmt.Println("Unknown error:", err) } }
Output:
codeItem not found - maybe create it?
How works:
code
errors.Is() codeError chain: "database query failed: not found" ↓ Check with errors.Is(err, ErrNotFound) ↓ Unwraps: "not found" ══════> ErrNotFound ✓ Match!
Using errors.As() to Extract Error Types
Extract specific error types from the chain:
gopackage main import ( "errors" "fmt" ) type TemporaryError struct { RetryAfter int // seconds Message string } func (e *TemporaryError) Error() string { return fmt.Sprintf("%s (retry after %d seconds)", e.Message, e.RetryAfter) } func apiCall() error { // Simulate temporary error tempErr := &TemporaryError{ RetryAfter: 30, Message: "rate limit exceeded", } // Wrap it with context return fmt.Errorf("API request failed: %w", tempErr) } func main() { err := apiCall() // Try to extract TemporaryError from error chain var tempErr *TemporaryError if errors.As(err, &tempErr) { fmt.Printf("Temporary failure: %s\n", tempErr.Message) fmt.Printf("Retry after %d seconds\n", tempErr.RetryAfter) } else { fmt.Println("Permanent failure:", err) } }
Output:
codeTemporary failure: rate limit exceeded Retry after 30 seconds
Complete Wrapping Example
Here's a real-world scenario showing all concepts:
gopackage main import ( "errors" "fmt" ) // Sentinel errors var ( ErrInvalidInput = errors.New("invalid input") ErrNotFound = errors.New("not found") ) // Custom error type type ValidationError struct { Field string Err error } func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed for %s: %v", e.Field, e.Err) } func (e *ValidationError) Unwrap() error { return e.Err } // Layer 1: Low-level validation func validateEmail(email string) error { if email == "" { return &ValidationError{ Field: "email", Err: ErrInvalidInput, } } if len(email) < 5 { return &ValidationError{ Field: "email", Err: fmt.Errorf("too short: %w", ErrInvalidInput), } } return nil } // Layer 2: User validation func validateUser(email string, age int) error { if err := validateEmail(email); err != nil { return fmt.Errorf("user validation failed: %w", err) } if age < 18 { return fmt.Errorf("user validation failed: age %d is below 18", age) } return nil } // Layer 3: Request handling func registerUser(email string, age int) error { if err := validateUser(email, age); err != nil { return fmt.Errorf("registration failed: %w", err) } // Simulate user already exists return fmt.Errorf("registration failed: user already exists: %w", ErrNotFound) } func main() { // Test case 1: Invalid email err := registerUser("", 25) if err != nil { fmt.Println("Error:", err) // Check for specific errors if errors.Is(err, ErrInvalidInput) { fmt.Println(" → This is an input validation error") } // Extract custom error type var validErr *ValidationError if errors.As(err, &validErr) { fmt.Printf(" → Failed field: %s\n", validErr.Field) } fmt.Println() } // Test case 2: Short email err = registerUser("ab", 25) if err != nil { fmt.Println("Error:", err) if errors.Is(err, ErrInvalidInput) { fmt.Println(" → This is an input validation error") } fmt.Println() } // Test case 3: Valid email but user exists err = registerUser("john@example.com", 25) if err != nil { fmt.Println("Error:", err) if errors.Is(err, ErrNotFound) { fmt.Println(" → User already exists") } } }
Output:
codeError: registration failed: user validation failed: validation failed for email: invalid input → This is an input validation error → Failed field: email Error: registration failed: user validation failed: validation failed for email: too short: invalid input → This is an input validation error Error: registration failed: user already exists: not found → User already exists
6. Sentinel Errors
What are Sentinel Errors?
Sentinel errors are predefined error values that you can compare against. Think of them as "error constants."
gopackage main import ( "errors" "fmt" ) // Define sentinel errors var ( ErrNotFound = errors.New("resource not found") ErrNoAccess = errors.New("access denied") ErrBadRequest = errors.New("bad request") ) func fetchUser(id int) error { if id < 0 { return ErrBadRequest } if id > 1000 { return ErrNotFound } return nil } func main() { err := fetchUser(-5) // Direct comparison if err == ErrBadRequest { fmt.Println("Invalid user ID provided") } // Using errors.Is (better for wrapped errors) err = fetchUser(2000) if errors.Is(err, ErrNotFound) { fmt.Println("User not found in database") } }
When to Use Sentinel Errors
Use sentinel errors when:
- The error is a well-known condition
- Callers need to make decisions based on the error
- The error doesn't need additional context
Example: Standard library uses this pattern
goimport ( "io" "os" ) // io package defines: // var EOF = errors.New("EOF") file, _ := os.Open("file.txt") buffer := make([]byte, 100) for { _, err := file.Read(buffer) if err == io.EOF { // Sentinel error fmt.Println("End of file reached") break } if err != nil { fmt.Println("Read error:", err) break } }
Advantages and Disadvantages
Advantages:
- Simple and clear
- Easy to compare
- Well-understood pattern
Disadvantages:
- Creates coupling (caller depends on specific error)
- Cannot add context without wrapping
- Changes to error break API
Best practice: Use with for wrapped errors
code
errors.Is()7. Error Types and Type Assertions
Type Assertions
Sometimes you need to access specific fields or methods of an error:
gopackage main import ( "fmt" "net" "time" ) func checkConnection(host string) error { // Try to connect with timeout conn, err := net.DialTimeout("tcp", host, 2*time.Second) if err != nil { return err } defer conn.Close() return nil } func main() { err := checkConnection("invalid-host:9999") if err != nil { // Try to extract network error details if netErr, ok := err.(net.Error); ok { fmt.Println("Network error occurred:") fmt.Printf(" Timeout: %v\n", netErr.Timeout()) fmt.Printf(" Temporary: %v\n", netErr.Temporary()) } else { fmt.Println("Other error:", err) } } }
Creating Behavior-Based Errors
Define interfaces for error behaviors:
gopackage main import ( "fmt" "time" ) // Define an interface for retryable errors type RetryableError interface { error RetryAfter() time.Duration } // Implement a retryable error type RateLimitError struct { message string retryAfter time.Duration } func (e *RateLimitError) Error() string { return e.message } func (e *RateLimitError) RetryAfter() time.Duration { return e.retryAfter } // Another retryable error type TemporaryNetworkError struct { message string } func (e *TemporaryNetworkError) Error() string { return e.message } func (e *TemporaryNetworkError) RetryAfter() time.Duration { return 5 * time.Second } // Function that returns retryable errors func apiCall(endpoint string) error { if endpoint == "rate-limited" { return &RateLimitError{ message: "rate limit exceeded", retryAfter: 30 * time.Second, } } if endpoint == "network-issue" { return &TemporaryNetworkError{ message: "temporary network error", } } return nil } func main() { endpoints := []string{"rate-limited", "network-issue", "normal"} for _, endpoint := range endpoints { err := apiCall(endpoint) if err != nil { // Check if error is retryable if retryErr, ok := err.(RetryableError); ok { fmt.Printf("Error: %v\n", retryErr) fmt.Printf(" Can retry after: %v\n", retryErr.RetryAfter()) } else { fmt.Printf("Fatal error: %v\n", err) } } else { fmt.Printf("Endpoint '%s' succeeded\n", endpoint) } } }
Output:
codeError: rate limit exceeded Can retry after: 30s Error: temporary network error Can retry after: 5s Endpoint 'normal' succeeded
8. Best Practices
1. Always Check Errors
DON'T:
gofile, _ := os.Open("important.txt") // Ignoring error! // If file doesn't exist, this will panic defer file.Close()
DO:
gofile, err := os.Open("important.txt") if err != nil { return fmt.Errorf("failed to open file: %w", err) } defer file.Close()
2. Provide Context When Wrapping
DON'T:
goif err != nil { return err // No context about what failed }
DO:
goif err != nil { return fmt.Errorf("failed to connect to database at %s: %w", dbHost, err) }
3. Use Consistent Error Messages
DON'T:
goerrors.New("Error: Failed to do something") // Redundant "Error:" errors.New("Something failed.") // Capital letter, period errors.New("FAILED!") // All caps
DO:
goerrors.New("failed to do something") // Lowercase, no period fmt.Errorf("failed to connect to %s", host) // Descriptive
Conventions:
- Start with lowercase
- No trailing punctuation
- Be specific and descriptive
- Use present tense
4. Don't Panic for Expected Errors
DON'T:
gofunc divide(a, b float64) float64 { if b == 0 { panic("division by zero") // DON'T PANIC! } return a / b }
DO:
gofunc divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil }
When to panic:
- Unrecoverable programmer errors
- Truly exceptional circumstances
- During initialization (before main loop)
5. Use Named Return Values for Clarity
go// Clear what each return value means func readConfig(filename string) (config Config, err error) { file, err := os.Open(filename) if err != nil { return Config{}, fmt.Errorf("cannot open config: %w", err) } defer file.Close() // ... more code return config, nil }
6. Handle Errors at the Right Level
gopackage main import ( "database/sql" "errors" "fmt" "log" ) // Low-level: Just return the error func queryDatabase(query string) (*sql.Rows, error) { // db.Query returns error, we just pass it up return db.Query(query) } // Mid-level: Add context func getUserByEmail(email string) (User, error) { rows, err := queryDatabase("SELECT * FROM users WHERE email = ?", email) if err != nil { return User{}, fmt.Errorf("failed to query user %s: %w", email, err) } defer rows.Close() // ... parse rows } // High-level: Decide what to do func handleLogin(email, password string) error { user, err := getUserByEmail(email) if err != nil { // Here we decide: log the error, return user-friendly message log.Printf("Login failed for %s: %v", email, err) return errors.New("invalid email or password") // Don't leak details } // ... verify password }
7. Return Errors, Don't Just Log
DON'T:
gofunc processFile(filename string) { file, err := os.Open(filename) if err != nil { log.Println("Error:", err) // Just logging return // Caller doesn't know it failed! } // ... }
DO:
gofunc processFile(filename string) error { file, err := os.Open(filename) if err != nil { return fmt.Errorf("failed to open %s: %w", filename, err) } // ... return nil } // Caller can decide whether to log func main() { if err := processFile("data.txt"); err != nil { log.Println("Fatal:", err) // Log at appropriate level os.Exit(1) } }
8. Use defer for Cleanup
gofunc processFile(filename string) error { file, err := os.Open(filename) if err != nil { return err } defer file.Close() // Ensures cleanup even if error occurs later // Multiple operations that might fail data, err := ioutil.ReadAll(file) if err != nil { return err // file.Close() will still be called } err = validateData(data) if err != nil { return err // file.Close() will still be called } return nil // file.Close() called here too }
9. Use errors.Is and errors.As for Wrapped Errors
DON'T:
goif err == ErrNotFound { // Won't work if error is wrapped // ... }
DO:
goif errors.Is(err, ErrNotFound) { // Works with wrapped errors // ... }
10. Document Error Behavior
go// FetchUser retrieves a user by ID. // // Returns ErrNotFound if the user doesn't exist. // Returns ErrNoAccess if the caller lacks permission. // Returns other errors for database or network failures. func FetchUser(id int) (User, error) { // ... }
9. Common Mistakes to Avoid
Mistake 1: Ignoring Errors with Blank Identifier
go// BAD: Silently ignoring errors file, _ := os.Open("config.json") data, _ := ioutil.ReadAll(file) _ = os.Remove("tempfile.txt") // GOOD: Handle each error file, err := os.Open("config.json") if err != nil { return fmt.Errorf("cannot open config: %w", err) } defer file.Close() data, err := ioutil.ReadAll(file) if err != nil { return fmt.Errorf("cannot read config: %w", err) } if err := os.Remove("tempfile.txt"); err != nil { log.Printf("Warning: failed to remove temp file: %v", err) }
Mistake 2: Not Using %w When Wrapping
go// BAD: Using %v loses the error chain if err != nil { return fmt.Errorf("database error: %v", err) } // Now errors.Is() and errors.As() won't work! // GOOD: Using %w preserves the error chain if err != nil { return fmt.Errorf("database error: %w", err) } // errors.Is() and errors.As() will work
Mistake 3: Returning Both Value and Error
go// BAD: Returning partial result with error func getUser(id int) (User, error) { user, err := fetchFromDB(id) if err != nil { // Don't return partial user! return user, err } return user, nil } // GOOD: Return zero value with error func getUser(id int) (User, error) { user, err := fetchFromDB(id) if err != nil { return User{}, fmt.Errorf("failed to get user %d: %w", id, err) } return user, nil }
Mistake 4: Overusing Panic
go// BAD: Panicking for recoverable errors func divide(a, b int) int { if b == 0 { panic("division by zero") // Crashes the program! } return a / b } // GOOD: Return error func divide(a, b int) (int, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil }
Mistake 5: Not Adding Context to Errors
go// BAD: No context if err != nil { return err } // GOOD: Add context about what operation failed if err != nil { return fmt.Errorf("failed to save user %s to database: %w", username, err) }
Mistake 6: Swallowing Errors in Goroutines
go// BAD: Error is lost go func() { _, err := riskyOperation() if err != nil { // Error is just logged, caller never knows! log.Println(err) } }() // GOOD: Send error through channel errCh := make(chan error, 1) go func() { _, err := riskyOperation() errCh <- err }() if err := <-errCh; err != nil { return fmt.Errorf("background operation failed: %w", err) }
Mistake 7: Creating Errors in Hot Paths
go// BAD: Creating new error on every call (slow) func validate(input string) error { if input == "" { return errors.New("input cannot be empty") // Allocates memory } return nil } // GOOD: Use sentinel error (no allocation) var ErrEmptyInput = errors.New("input cannot be empty") func validate(input string) error { if input == "" { return ErrEmptyInput // Just returns pointer } return nil }
10. Real-World Examples
Example 1: HTTP Server with Proper Error Handling
gopackage main import ( "database/sql" "encoding/json" "errors" "fmt" "log" "net/http" ) // Custom errors var ( ErrUserNotFound = errors.New("user not found") ErrInvalidInput = errors.New("invalid input") ErrDatabaseFailure = errors.New("database failure") ) // User type type User struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` } // Error response type ErrorResponse struct { Error string `json:"error"` Message string `json:"message"` } // Database layer func getUserFromDB(id int) (User, error) { // Simulate database query if id <= 0 { return User{}, ErrInvalidInput } // Simulate user not found if id > 1000 { return User{}, ErrUserNotFound } // Simulate database error if id == 666 { return User{}, fmt.Errorf("connection failed: %w", ErrDatabaseFailure) } return User{ ID: id, Name: "John Doe", Email: "john@example.com", }, nil } // Service layer func getUser(id int) (User, error) { user, err := getUserFromDB(id) if err != nil { return User{}, fmt.Errorf("failed to fetch user %d: %w", id, err) } return user, nil } // HTTP handler func userHandler(w http.ResponseWriter, r *http.Request) { // Parse user ID from query idStr := r.URL.Query().Get("id") if idStr == "" { writeError(w, http.StatusBadRequest, "id parameter required", ErrInvalidInput) return } var id int _, err := fmt.Sscanf(idStr, "%d", &id) if err != nil { writeError(w, http.StatusBadRequest, "invalid id format", ErrInvalidInput) return } // Fetch user user, err := getUser(id) if err != nil { handleError(w, err) return } // Success response w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(user) } // Error handling logic func handleError(w http.ResponseWriter, err error) { // Log the full error log.Printf("Error: %v", err) // Determine HTTP status and user message based on error type switch { case errors.Is(err, ErrUserNotFound): writeError(w, http.StatusNotFound, "User not found", err) case errors.Is(err, ErrInvalidInput): writeError(w, http.StatusBadRequest, "Invalid input provided", err) case errors.Is(err, ErrDatabaseFailure): writeError(w, http.StatusInternalServerError, "Service temporarily unavailable", err) default: writeError(w, http.StatusInternalServerError, "Internal server error", err) } } // Write error response func writeError(w http.ResponseWriter, status int, message string, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) response := ErrorResponse{ Error: http.StatusText(status), Message: message, } json.NewEncoder(w).Encode(response) } func main() { http.HandleFunc("/user", userHandler) fmt.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal("Server failed:", err) } }
Example 2: File Processing with Cleanup
gopackage main import ( "bufio" "fmt" "os" "strings" ) // Process a file line by line func processFile(inputPath, outputPath string) error { // Open input file inputFile, err := os.Open(inputPath) if err != nil { return fmt.Errorf("cannot open input file %s: %w", inputPath, err) } defer inputFile.Close() // Create output file outputFile, err := os.Create(outputPath) if err != nil { return fmt.Errorf("cannot create output file %s: %w", outputPath, err) } defer func() { if err := outputFile.Close(); err != nil { fmt.Printf("Warning: failed to close output file: %v\n", err) } }() // Process line by line scanner := bufio.NewScanner(inputFile) writer := bufio.NewWriter(outputFile) defer writer.Flush() lineNum := 0 for scanner.Scan() { lineNum++ line := scanner.Text() // Process line (e.g., convert to uppercase) processed := strings.ToUpper(line) // Write to output if _, err := writer.WriteString(processed + "\n"); err != nil { return fmt.Errorf("failed to write line %d: %w", lineNum, err) } } // Check for scanner errors if err := scanner.Err(); err != nil { return fmt.Errorf("error reading input file at line %d: %w", lineNum, err) } return nil } func main() { if err := processFile("input.txt", "output.txt"); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } fmt.Println("File processed successfully") }
Example 3: Retry Logic with Backoff
gopackage main import ( "errors" "fmt" "math/rand" "time" ) // Retryable error interface type TemporaryError interface { error Temporary() bool } // Network error implementation type NetworkError struct { message string } func (e *NetworkError) Error() string { return e.message } func (e *NetworkError) Temporary() bool { return true } // Simulate an unreliable operation func unreliableOperation() error { if rand.Float32() < 0.7 { // 70% failure rate return &NetworkError{message: "connection timeout"} } return nil } // Retry with exponential backoff func retryWithBackoff(operation func() error, maxRetries int) error { var err error for attempt := 0; attempt <= maxRetries; attempt++ { // Try the operation err = operation() // Success! if err == nil { if attempt > 0 { fmt.Printf("Succeeded on attempt %d\n", attempt+1) } return nil } // Check if error is temporary var tempErr TemporaryError if !errors.As(err, &tempErr) || !tempErr.Temporary() { // Permanent error, don't retry return fmt.Errorf("permanent failure: %w", err) } // Last attempt failed if attempt == maxRetries { return fmt.Errorf("max retries (%d) exceeded: %w", maxRetries, err) } // Calculate backoff duration (exponential: 1s, 2s, 4s, 8s...) backoff := time.Duration(1<<uint(attempt)) * time.Second fmt.Printf("Attempt %d failed: %v. Retrying in %v...\n", attempt+1, err, backoff) time.Sleep(backoff) } return err } func main() { rand.Seed(time.Now().UnixNano()) fmt.Println("Starting unreliable operation with retry logic...") err := retryWithBackoff(unreliableOperation, 5) if err != nil { fmt.Printf("Operation failed: %v\n", err) return } fmt.Println("Operation completed successfully!") }
11. Comparison with Other Languages
Go vs Java/C# (Exceptions)
Java:
javapublic User getUser(int id) throws UserNotFoundException, DatabaseException { try { return database.query("SELECT * FROM users WHERE id = ?", id); } catch (SQLException e) { throw new DatabaseException("Failed to query user", e); } } // Caller try { User user = getUser(123); processUser(user); } catch (UserNotFoundException e) { System.out.println("User not found"); } catch (DatabaseException e) { System.out.println("Database error"); }
Go:
gofunc getUser(id int) (User, error) { user, err := database.Query("SELECT * FROM users WHERE id = ?", id) if err != nil { return User{}, fmt.Errorf("failed to query user: %w", err) } return user, nil } // Caller user, err := getUser(123) if err != nil { if errors.Is(err, ErrUserNotFound) { fmt.Println("User not found") } else { fmt.Println("Database error:", err) } return } processUser(user)
Key differences:
- Go: Explicit error checking at every step
- Java: Errors can bubble up automatically
- Go: Errors are values, can be inspected and manipulated
- Java: Exceptions have special control flow
Go vs Python (Try/Except)
Python:
pythondef read_config(filename): try: with open(filename) as f: return json.load(f) except FileNotFoundError: return default_config() except json.JSONDecodeError as e: raise ConfigError(f"Invalid JSON: {e}") # Caller try: config = read_config("config.json") process(config) except ConfigError as e: print(f"Config error: {e}")
Go:
gofunc readConfig(filename string) (Config, error) { data, err := os.ReadFile(filename) if err != nil { if os.IsNotExist(err) { return defaultConfig(), nil } return Config{}, fmt.Errorf("cannot read config: %w", err) } var config Config if err := json.Unmarshal(data, &config); err != nil { return Config{}, fmt.Errorf("invalid JSON: %w", err) } return config, nil } // Caller config, err := readConfig("config.json") if err != nil { fmt.Printf("Config error: %v\n", err) return } process(config)
Go vs Rust (Result Type)
Rust:
rustfn divide(a: f64, b: f64) -> Result<f64, String> { if b == 0.0 { Err("division by zero".to_string()) } else { Ok(a / b) } } // Caller match divide(10.0, 2.0) { Ok(result) => println!("Result: {}", result), Err(e) => println!("Error: {}", e), }
Go:
gofunc divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil } // Caller result, err := divide(10.0, 2.0) if err != nil { fmt.Println("Error:", err) return } fmt.Println("Result:", result)
Similarities:
- Both return errors as values
- Both require explicit handling
- Both make error paths visible
Differences:
- Rust: Compiler enforces error handling (must use Result)
- Go: Can ignore errors (but it's obvious)
- Rust: Pattern matching on Result
- Go: Simple if-check on error
12. Summary
Key Takeaways
-
Errors are Values
- In Go, errors are regular values, not special control flow
- This makes error handling explicit and visible
- You can manipulate, inspect, and pass errors like any other value
-
The Error Interfacego
type error interface { Error() string }- Any type with an
method is an errorcodeError() - Simple and flexible design
- Any type with an
-
Basic Patterngo
result, err := operation() if err != nil { // Handle error return fmt.Errorf("context: %w", err) } // Use result -
Error Wrapping (Go 1.13+)
- Use
to wrap errors with contextcode%w - Use
to check for specific errorscodeerrors.Is() - Use
to extract error typescodeerrors.As()
- Use
-
Custom Errors
- Create custom error types for structured information
- Implement
methodcodeError() - Optionally implement
for error chainscodeUnwrap()
-
Sentinel Errors
- Define package-level error variables
- Allow callers to check for specific conditions
- Use with
for wrapped errorscodeerrors.Is()
Best Practices Checklist
✅ Always check errors - Don't use to ignore them
✅ Add context - Wrap errors with
✅ Use lowercase - Start error messages with lowercase
✅ Be specific - Include relevant details (file names, IDs, etc.)
✅ Return early - Handle errors and return immediately
✅ Document errors - Describe what errors a function can return
✅ Use defer - Ensure cleanup happens even on error
✅ Don't panic - Use errors for expected failures
✅ Use errors.Is/As - For checking wrapped errors
✅ Log at boundaries - Log errors at the top level, not everywhere
code
_code
fmt.Errorf("context: %w", err)Common Patterns Summary
Error Creation:
goerrors.New("simple message") fmt.Errorf("formatted %s message", value) fmt.Errorf("wrapped: %w", originalErr)
Error Checking:
goif err != nil { return err } if errors.Is(err, ErrSpecific) { // Handle specific error } var customErr *CustomError if errors.As(err, &customErr) { // Access custom error fields }
Error Wrapping:
goif err != nil { return fmt.Errorf("operation failed: %w", err) }
Custom Errors:
gotype MyError struct { Field string Err error } func (e *MyError) Error() string { return fmt.Sprintf("error in %s: %v", e.Field, e.Err) } func (e *MyError) Unwrap() error { return e.Err }
Why Error Handling Matters
- Reliability - Systems fail gracefully instead of crashing
- Debuggability - Clear error messages help diagnose problems
- User Experience - Informative error messages guide users
- Security - Proper error handling prevents information leaks
- Maintainability - Explicit error paths make code easier to understand
When to Use What
| Scenario | Approach |
|---|---|
| Simple error | code |
| Dynamic message | code |
| Wrap existing error | code |
| Multiple fields | Custom error type |
| Known conditions | Sentinel errors |
| Behavior checking | Error interfaces |
| Network/IO ops | Check error types with code |
Final Thoughts
Error handling in Go may seem verbose compared to exceptions, but it has significant advantages:
- Clarity: You can see exactly where errors are handled
- Simplicity: No hidden control flow or magic
- Flexibility: Errors are just values you can work with
- Reliability: Forces you to think about failure cases
The key is to embrace the Go way: treat errors as first-class values, handle them explicitly, and provide useful context when things go wrong.
Remember: Good error handling is not about preventing errors—it's about handling them gracefully when they inevitably occur.
Additional Resources
- Go Blog: Error Handling and Go
- Go Blog: Working with Errors in Go 1.13
- Effective Go: Errors
- Go by Example: Errors
- [Dave Cheney: Don't just check errors, handle them gracefully](https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-grac
Was this helpful?