type-system
1w ago

Explain error handling in go

12 views • 0 upvotes

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

  1. Introduction: Why Error Handling Matters
  2. Understanding Errors in Go
  3. Basic Error Handling Patterns
  4. Creating Custom Errors
  5. Error Wrapping and Unwrapping (Go 1.13+)
  6. Sentinel Errors
  7. Error Types and Type Assertions
  8. Best Practices
  9. Common Mistakes to Avoid
  10. Real-World Examples
  11. Comparison with Other Languages
  12. 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:
  1. Detecting when something goes wrong
  2. Communicating what went wrong
  3. Responding appropriately to the problem
  4. 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?
  1. Explicit: You can see exactly where errors are checked
  2. Simple: No hidden control flow
  3. Flexible: Errors are just values you can manipulate
  4. 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:
go
type error interface {
    Error() string
}
What does this mean?
Any type that has a method called
code
Error()
which returns a
code
string
can be used as an error. That's it!
Let's see the simplest possible error:
go
package 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:
  1. We created a type
    code
    MyError
    with a
    code
    Message
    field
  2. We gave it an
    code
    Error()
    method that returns a string
  3. Now
    code
    MyError
    satisfies the
    code
    error
    interface
  4. 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
code
errors.New()

go
package 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:
go
func 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
code
fmt.Errorf()

When you need to include dynamic information in your error message:
go
package 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
code
nil

In Go,
code
nil
represents "nothing" or "no value". For errors:
go
var err error = nil  // No error occurred
Visual representation:
code
Function succeeds:
┌──────────────┐
│  Function    │
│  executes    │ ──► Returns: (result, nil)
│  correctly   │              ↑
└──────────────┘              │
                              No error!

Function fails:
┌──────────────┐
│  Function    │
│  encounters  │ ──► Returns: (zero-value, error)
│  problem     │                           ↑
└──────────────┘                           │
                                       Error details
Key concept: Always check
code
if err != nil
after operations that can fail.

3. Basic Error Handling Patterns

Pattern 1: The Standard Check

This is the pattern you'll see everywhere in Go code:
go
package 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:
code
Start
  ↓
Open file
  ↓
Error? ──Yes──> Print error ──> Exit
  ↓ No
Continue with file
  ↓
Close file
  ↓
End
The pattern:
  1. Call function that returns
    code
    (result, error)
  2. Immediately check:
    code
    if err != nil
  3. Handle error (often return or exit)
  4. Continue if no error

Pattern 2: Early Returns (Guard Clauses)

Handle errors early and keep the "happy path" left-aligned:
go
package 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:
go
package 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:
code
Error 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:
go
package 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:
go
result, 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
code
fmt.Errorf()
for simple cases:
go
package 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:
code
Validation 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:
go
package 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:
code
Error: 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:
go
package 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:
code
Error: [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:
go
func 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
code
%w
verb for wrapping errors:
go
package 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:
code
Original 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:
go
package 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:
code
Item not found - maybe create it?
How
code
errors.Is()
works:
code
Error 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:
go
package 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:
code
Temporary failure: rate limit exceeded
Retry after 30 seconds

Complete Wrapping Example

Here's a real-world scenario showing all concepts:
go
package 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:
code
Error: 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."
go
package 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
go
import (
    "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
code
errors.Is()
for wrapped errors

7. Error Types and Type Assertions

Type Assertions

Sometimes you need to access specific fields or methods of an error:
go
package 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:
go
package 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:
code
Error: 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:
go
file, _ := os.Open("important.txt")  // Ignoring error!
// If file doesn't exist, this will panic
defer file.Close()
DO:
go
file, 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:
go
if err != nil {
    return err  // No context about what failed
}
DO:
go
if err != nil {
    return fmt.Errorf("failed to connect to database at %s: %w", dbHost, err)
}

3. Use Consistent Error Messages

DON'T:
go
errors.New("Error: Failed to do something")  // Redundant "Error:"
errors.New("Something failed.")              // Capital letter, period
errors.New("FAILED!")                        // All caps
DO:
go
errors.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:
go
func divide(a, b float64) float64 {
    if b == 0 {
        panic("division by zero")  // DON'T PANIC!
    }
    return a / b
}
DO:
go
func 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

go
package 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:
go
func 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:
go
func 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

go
func 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:
go
if err == ErrNotFound {  // Won't work if error is wrapped
    // ...
}
DO:
go
if 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

go
package 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

go
package 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

go
package 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:
java
public 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:
go
func 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:
python
def 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:
go
func 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:
rust
fn 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:
go
func 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

  1. 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
  2. The Error Interface
    go
    type error interface {
        Error() string
    }
    
    • Any type with an
      code
      Error()
      method is an error
    • Simple and flexible design
  3. Basic Pattern
    go
    result, err := operation()
    if err != nil {
        // Handle error
        return fmt.Errorf("context: %w", err)
    }
    // Use result
    
  4. Error Wrapping (Go 1.13+)
    • Use
      code
      %w
      to wrap errors with context
    • Use
      code
      errors.Is()
      to check for specific errors
    • Use
      code
      errors.As()
      to extract error types
  5. Custom Errors
    • Create custom error types for structured information
    • Implement
      code
      Error()
      method
    • Optionally implement
      code
      Unwrap()
      for error chains
  6. Sentinel Errors
    • Define package-level error variables
    • Allow callers to check for specific conditions
    • Use with
      code
      errors.Is()
      for wrapped errors

Best Practices Checklist

Always check errors - Don't use
code
_
to ignore them ✅ Add context - Wrap errors with
code
fmt.Errorf("context: %w", err)
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

Common Patterns Summary

Error Creation:
go
errors.New("simple message")
fmt.Errorf("formatted %s message", value)
fmt.Errorf("wrapped: %w", originalErr)
Error Checking:
go
if 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:
go
if err != nil {
    return fmt.Errorf("operation failed: %w", err)
}
Custom Errors:
go
type 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

  1. Reliability - Systems fail gracefully instead of crashing
  2. Debuggability - Clear error messages help diagnose problems
  3. User Experience - Informative error messages guide users
  4. Security - Proper error handling prevents information leaks
  5. Maintainability - Explicit error paths make code easier to understand

When to Use What

ScenarioApproach
Simple error
code
errors.New("message")
Dynamic message
code
fmt.Errorf("message: %s", value)
Wrap existing error
code
fmt.Errorf("context: %w", err)
Multiple fieldsCustom error type
Known conditionsSentinel errors
Behavior checkingError interfaces
Network/IO opsCheck error types with
code
errors.As()

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

Was this helpful?

Difficulty & Status

medium
Lvl. 4
Community Verified

Related Topics

memorytype coversionstype safetystring handling
Progress: 50%
Answered by: shubham vyasPrev TopicNext Topic