Errors in Go: Values, Not Exceptions

Go handles errors differently—no exceptions like in Python or Java. Instead, errors are returned as values, making code more explicit and predictable.

1. Go’s Error Philosophy

  • Errors are values. They are not disruptive control flow tools, but part of the normal data returned from functions.

  • No exceptions. There is no try/catch or stack unwinding. You handle errors where they occur.

  • Explicit is better than implicit. Go prefers repetitive clarity (if err != nil) over hidden or magical error handling.

“Errors are just values. They’re part of the normal flow of control.” — Rob Pike, Go co-creator


2. The Basics: Returning Errors Explicitly

A function that can fail typically returns two values: the result (if any) and an error.

import (
    "errors"
    "fmt"
)

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

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}
  • If b is zero, we return a custom error. Otherwise, the division proceeds and err is nil.


3. Idiomatic Checking: if err != nil

This is the most common pattern in Go:

value, err := someFunc()
if err != nil {
    // handle error
}
  • Why so repetitive?

    • You always see where errors might occur—no hidden control flow.

    • You decide how to handle each error, right after the function call.


4. Creating and Propagating Errors

Creating Errors

  • Use errors.New("message") for simple errors.

  • Use fmt.Errorf("context: %w", err) to wrap errors with context.

import (
    "errors"
    "fmt"
)

func readConfig() error {
    return errors.New("config file not found")
}

func loadApp() error {
    err := readConfig()
    if err != nil {
        return fmt.Errorf("loadApp failed: %w", err)
    }
    return nil
}
  • %w in fmt.Errorf wraps the original error, preserving the trace.


5. Sentinel Errors: Known, Comparable Values

You can define specific errors for comparison:

var ErrNotFound = errors.New("not found")

func findUser(id int) error {
    // ...
    return ErrNotFound
}

if errors.Is(err, ErrNotFound) {
    // handle not found
}
  • Use errors.Is() for type-safe error comparison.


6. Custom Error Types

For richer error data, define your own error type:

type HTTPError struct {
    Code int
    Message string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}

func fetch() error {
    return &HTTPError{Code: 404, Message: "Not Found"}
}

err := fetch()
if httpErr, ok := err.(*HTTPError); ok {
    fmt.Println("Status code:", httpErr.Code)
}
  • Use errors.As() to check and extract custom error types.


7. Best Practices

  • Handle errors immediately or explicitly propagate them.

  • Never ignore errors (don’t use _ unless you’re absolutely sure).

  • Use fmt.Errorf for adding context.

  • Prefer errors.Is() and errors.As() for type-safe matching.

  • Keep error messages clear and user-focused.

  • Avoid panics for normal error handling (use only for truly unrecoverable situations).


8. Anti-Patterns

  • Don’t suppress errors: Always check and handle them.

  • Don’t use panic/recover for flow control: Reserve for bugs or truly exceptional cases.

  • Don’t rely on string-matching error messages: Use sentinel errors or custom types.


9. Real-World Example: Error Propagation in Cloud APIs

Suppose you’re building a cloud service that fetches metadata from a provider and saves it to a database. Each step can fail:

func fetchMetadata() (Metadata, error) { /* ... */ }
func saveToDB(data Metadata) error { /* ... */ }

func process() error {
    data, err := fetchMetadata()
    if err != nil {
        return fmt.Errorf("fetch failed: %w", err)
    }
    if err := saveToDB(data); err != nil {
        return fmt.Errorf("db save failed: %w", err)
    }
    return nil
}

func main() {
    if err := process(); err != nil {
        log.Fatalf("process failed: %v", err)
    }
}
  • Error propagation is explicit and context-aware.

  • Zero values ensure structs like Metadata are safe to use even before initialization.

  • Short if err != nil patterns keep logic close to where failures can occur.

  • defer can be used for resource cleanup (e.g., closing files or network connections).


10. Go vs. Exception-Based Languages

  • No hidden control flow: Errors are handled where they occur, not by unwinding the stack.

  • No try/catch: You always see error handling logic.

  • No stack trace drama: You’re in control of every potential failure and its propagation.

  • Composability: Errors can be stored, wrapped, compared, or logged like any other value.


11. Practice & Conceptual Questions

  1. Write a function that returns an error if a file does not exist.

  2. Create a custom error type for HTTP errors and demonstrate type assertion.

  3. Use fmt.Errorf to wrap an error with context and extract the original error using errors.Is.

  4. Refactor a function to propagate errors up the call stack with context.

  5. Show how to use defer for resource cleanup in the presence of errors.

  6. Compare Go’s error handling to exception-based languages. What are the trade-offs?


Further Reading

Last updated