GoBackend

Go Error Handling Patterns: The Complete Guide

TT
TopicTrick Team
Go Error Handling Patterns: The Complete Guide

Go Error Handling Patterns: Complete Guide

Go handles errors by returning them as values from functions, typically as the last return value of type error. Callers must check the returned error immediately and decide whether to handle it, wrap it with additional context, or propagate it up the call stack. Unlike exceptions, there is no automatic stack unwinding — execution remains linear and predictable. The idiomatic check is if err != nil { return fmt.Errorf("operation: %w", err) }.

Error Handling Patterns: The Go Way

In most modern languages, errors are handled via a try/catch mechanism. When something goes wrong, an exception is "thrown," and the control flow jumps to the nearest handler. Go takes a radically different approach.

In Go, errors are values. They are returned from functions just like integers or strings. This design choice forces you to acknowledge and handle errors immediately where they occur, rather than letting them bubble up and explode unexpectedly later.


1. The Error Mirror: Visibility vs. Unwinding

In traditional languages, an "Exception" causes a violent disruption of the execution flow known as Stack Unwinding.

The Flow Physics

  • The Unwinding Cost: When an exception is thrown, the CPU must stop, walk back through every open function frame, and find a matching catch. This is a heavyweight runtime operation that can take thousands of CPU cycles.
  • The Go Mirror: Go's if err != nil is a "Local Branch." The CPU's branch predictor sees it as a standard conditional jump. If the error doesn't happen (99% of the time), the CPU speeds through the code with zero overhead.
  • The Result: Go's "Explicit Errors" are a hardware-friendly way to handle failure, ensuring that even your "Bad Paths" are predictable and high-performance.

2. The "Check and Return" Idiom

The most common pattern in Go is returning a value and an error as a tuple. If the error is not nil, you stop execution and return the error up the stack.

go
func main() {
    file, err := os.Open("config.json")
    if err != nil {
        // Handle the error immediately!
        log.Fatalf("Failed to open config: %v", err)
    }
    
    // If we reach here, 'file' is safe to use
    defer file.Close()
}

This "if err != nil" pattern might seem repetitive at first, but it ensures that you never use a "corrupted" or "nilled" value by accident.

Creating and Customizing Errors

Simple errors can be created using the errors.New function or fmt.Errorf if you need to include dynamic data.

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

Wrapping and Unwrapping

Sometimes you want to add context to an error as it moves up the call stack. Go's fmt.Errorf supports the %w verb to "wrap" an error.

go
if err != nil {
    return fmt.Errorf("database query failed: %w", err)
}

4. The Interface Mirror: Inside the error Type

You might think error is a primitive, but it is actually the world's most simple Interface.

The Architecture Physics

  • The Definition: type error interface { Error() string }.
  • The Nil Signal: In Go's runtime, a nil error is just a pointer to the address 0x0. Checking if err != nil is one of the fastest operations a computer can perform.
  • The Wrap Mirror: When you wrap an error with %w, Go creates a new struct that embeds the old error. This forms a "Linked List" of failures, allowing you to trace the "Cause Chains" without losing the original silicon signal.

5. Advanced: Custom Error Types

Because error is just an interface in Go (anything with an Error() string method), you can create your own error structs to carry extra diagnostic data.

go
type APIError struct {
    Code    int
    Message string
}

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

Error Handling Tools

No data available
Task / FeatureTraditional ExceptionsGo Explicit Errors
No comparison data available

The Error Wrapping Chain

Go 1.13 introduced formal error wrapping via the %w verb in fmt.Errorf. This creates a chain of errors where each error wraps its cause. You can then inspect the chain with errors.Is and errors.As:

go
// A chain: "service: database: connection refused"
var ErrConnectionRefused = errors.New("connection refused")

func connectDB() error {
    return fmt.Errorf("database: %w", ErrConnectionRefused)
}

func startService() error {
    if err := connectDB(); err != nil {
        return fmt.Errorf("service: %w", err)
    }
    return nil
}

func main() {
    err := startService()
    
    // errors.Is checks the entire chain
    if errors.Is(err, ErrConnectionRefused) {
        fmt.Println("Cannot reach the database") // This prints!
    }
    
    // err.Error() gives you the full chain message
    fmt.Println(err) // "service: database: connection refused"
}

This chain structure means you get both a human-readable error message with context at every layer AND the ability to programmatically check what the root cause was.


Practical Pattern: Repository Error Types

For production services, a clean pattern is defining error types at the repository layer that the service and handler layers can inspect:

go
// errors.go (in the repository package)
type NotFoundError struct {
    Resource string
    ID       int
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}

// In the handler
var notFound *repository.NotFoundError
if errors.As(err, &notFound) {
    http.Error(w, notFound.Error(), http.StatusNotFound)
    return
}

This gives your HTTP handler precise control over status codes without needing to know the internal details of the error — it just asks "is this a not-found error?"


Reducing if err != nil Boilerplate

The repetitive if err != nil pattern is one of Go's most common criticisms. A few techniques reduce the noise:

The Early Return Pattern

Organise your code to return early on error at each step, keeping the "happy path" at the left margin:

go
// Each failure returns early — the success path flows naturally
func processUser(id int) (*User, error) {
    user, err := db.FindUser(id)
    if err != nil {
        return nil, fmt.Errorf("findUser: %w", err)
    }
    
    if err := user.Validate(); err != nil {
        return nil, fmt.Errorf("validate: %w", err)
    }
    
    if err := cache.Store(user); err != nil {
        return nil, fmt.Errorf("cache: %w", err)
    }
    
    return user, nil
}

Error Accumulator for Validation

When you need to collect multiple errors (like form validation), accumulate them:

go
type ValidationError struct {
    Fields map[string]string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: %v", e.Fields)
}

func validateUser(u User) error {
    errs := &ValidationError{Fields: make(map[string]string)}
    
    if u.Name == "" {
        errs.Fields["name"] = "name is required"
    }
    if len(u.Password) < 8 {
        errs.Fields["password"] = "password must be at least 8 characters"
    }
    
    if len(errs.Fields) > 0 {
        return errs
    }
    return nil
}

Further Reading

For Go's catastrophic error handling mechanism, the next post covers Go panic, recover, and defer. For a different perspective on error handling, see our Python exception handling guide to contrast Go's approach with Python's try/except model. For testing your error handling logic, see Go testing with unit tests and benchmarks.


Phase 8: Failure Architecture Mastery Checklist

  • Verify "Early Return" Hygiene: Audit your functions to ensure that the "Happy Path" remains at the left margin, with errors returning early.
  • Audit Error Wrapping: Confirm that you are using %w when adding context to errors to preserve the underlying "Silicon Signal."
  • Implement Sentinel Comparisons: Use errors.Is(err, target) instead of == to ensure your checks work across wrapped error chains.
  • Test Custom Error Types: Implement custom structs for errors that need to carry status codes, field names, or retry-delays.
  • Use "errors.As" for Casting: Cast generalized errors to specific custom types only when you need to act on their internal metadata.

Read next: Go Panic, Recover, and Defer: The Stability Mirror →



Next Steps

Now that you can handle errors like a pro, we need to talk about what happens when things go catastrophically wrong. In our next tutorial, we will explore Panic, Recover, and Defer, and learn how to manage the application lifecycle even during a crash.

Common Go Error Handling Mistakes

1. Swallowing errors with _ result, _ := doSomething() silently discards the error. This is only acceptable for operations that truly cannot fail (like writing to a bytes.Buffer). For anything that can fail — file I/O, network calls, database queries — always check the error.

2. Using fmt.Errorf without %w when wrapping fmt.Errorf("failed: %v", err) creates a new error that loses the original type, making errors.Is and errors.As fail. Use fmt.Errorf("failed: %w", err) to wrap and preserve the original error for unwrapping.

3. Comparing errors with == instead of errors.Is err == io.EOF works for sentinel errors, but fails for wrapped errors. errors.Is(err, io.EOF) traverses the error chain and handles wrapping correctly. Always prefer errors.Is and errors.As over direct comparison. See the Go blog on error handling.

4. Panicking on expected errors panic is for unrecoverable programmer errors (nil dereference, index out of bounds). Expected runtime errors — file not found, network timeout — should be returned as values. Reserve panic for truly exceptional situations.

5. Over-wrapping errors Wrapping every error through multiple layers with the same message produces noise: "service: repo: db: query: sql: no rows". Wrap at meaningful abstraction boundaries and skip wrapping when the message adds no context.

Frequently Asked Questions

What is the difference between errors.Is and errors.As? errors.Is(err, target) checks whether any error in the chain matches target by value (for sentinel errors) or implements a Is(error) bool method. errors.As(err, &target) checks whether any error in the chain can be assigned to target's type, letting you extract a concrete error type. The Go errors package docs cover both in detail.

Should I define custom error types or use errors.New? Use errors.New("message") for simple sentinel errors that callers check with errors.Is. Define a custom error type (a struct implementing the error interface) when the error needs to carry additional data — for example, an HTTP status code or a field name that failed validation.

How does Go's error handling compare to exceptions? Go returns errors as values rather than throwing them. This makes error paths explicit and forces the caller to decide how to handle each error immediately. The downside is verbosity. The upside is that control flow is always visible — no hidden exception propagation through call stacks.