Mastering Go Error Handling: A Guide to errors.Is, errors.As, and Custom Wrappers

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The Evolution of Error Handling in Go

Early in my career as a Gopher, I often found myself frustrated with how Go handled errors. Coming from languages with try-catch blocks, the repetitive if err != nil felt clunky. However, as I built larger systems, I realized that Go’s explicit error handling is actually its greatest strength. It forces you to think about failures at every step.

Before Go 1.13, we were limited to simple sentinel errors (like io.EOF) or checking error strings. This made it difficult to add context to an error without losing the original error’s identity. If you wrapped an error in a new string, you could no longer check if it was originally a ‘Not Found’ error without messy string parsing.

Comparing Approaches: Old School vs. Modern Wrapping

To understand why we use errors.Is and errors.As, we need to look at how things used to be done versus the modern standard.

The Traditional Way (Pre-Go 1.13)

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

func findUser(id string) error {
    return fmt.Errorf("user %s: %v", id, ErrNotFound)
}

// Checking the error
err := findUser("123")
if err == ErrNotFound { // This fails! The error is now a different string.
    // Handle not found
}

The Modern Way (Go 1.13+)

func findUser(id string) error {
    return fmt.Errorf("user %s: %w", id, ErrNotFound) // Notice the %w
}

// Checking the error
err := findUser("123")
if errors.Is(err, ErrNotFound) { // This works! It unwrap the chain.
    // Handle not found
}

Pros and Cons of Manual vs. Wrapped Errors

Choosing the right strategy depends on the complexity of your application. Here is a breakdown of the trade-offs.

Simple String Errors

  • Pros: Fast to write, low memory overhead.
  • Cons: Hard to debug in production, impossible to programmatically categorize without string matching.

Wrapped Errors (%w)

  • Pros: Preserves the original error (the “cause”), allows adding context (like IDs or operation names), works perfectly with errors.Is.
  • Cons: Slightly more verbose, can expose internal implementation details if not careful.

Custom Error Structs

  • Pros: Can carry structured data (HTTP codes, machine-readable error codes), highly flexible.
  • Cons: Requires more boilerplate code, needs errors.As to retrieve the data.

Recommended Setup for Production Apps

In my real-world experience, this is one of the essential skills to master if you want to move from writing scripts to building resilient microservices. I recommend a three-tier approach to error handling:

  1. Use Sentinel Errors for common, expected state failures (e.g., ErrDataNotFound, ErrUnauthorized).
  2. Always wrap errors at every layer of your application using fmt.Errorf("...: %w", err). This creates a stack trace-like effect.
  3. Use a Custom Wrapper at the boundary of your application (like the API handler or the CLI entry point) to translate internal errors into user-friendly responses.

Implementation Guide: Mastering the Tools

1. Identifying Errors with errors.Is

Use errors.Is when you want to check if an error matches a specific sentinel value. It recursively unwraps the error chain to see if any error in the history matches your target.

var ErrDatabaseDown = errors.New("db connection lost")

func queryRow() error {
    return fmt.Errorf("repository layer: %w", ErrDatabaseDown)
}

func serviceLayer() error {
    return fmt.Errorf("service layer: %w", queryRow())
}

func main() {
    err := serviceLayer()
    if errors.Is(err, ErrDatabaseDown) {
        fmt.Println("We need to retry the connection!")
    }
}

2. Extracting Data with errors.As

Sometimes you need more than just a yes/no check. You might need to access a specific field inside a custom error struct. This is where errors.As shines.

type APIError struct {
    StatusCode int
    Message    string
}

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

func callExternalAPI() error {
    return &APIError{StatusCode: 404, Message: "User not found in remote system"}
}

func main() {
    err := callExternalAPI()
    
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        fmt.Printf("Logging specific status: %d\n", apiErr.StatusCode)
    }
}

3. Building a Professional Custom Error Wrapper

For a production-grade application, you often want to attach metadata like a “Request ID” or a “Domain Code” to every error. Here is a pattern I use to create a robust wrapper that maintains compatibility with Go’s standard library.

package main

import (
    "errors"
    "fmt"
)

// AppError is our custom wrapper
type AppError struct {
    Code    string
    Message string
    Err     error // The original error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

// Unwrap allows errors.Is and errors.As to work
func (e *AppError) Unwrap() error {
    return e.Err
}

func NewAppError(code, message string, err error) error {
    return &AppError{
        Code:    code,
        Message: message,
        Err:     err,
    }
}

func main() {
    // Simulate a database error
    dbErr := errors.New("sql: connection timeout")
    
    // Wrap it with domain context
    wrapped := NewAppError("DB_TIMEOUT", "failed to fetch user profile", dbErr)
    
    // Later in the transport layer (HTTP/gRPC)
    fmt.Println(wrapped.Error())
    
    // We can still check the original cause
    if errors.Is(wrapped, dbErr) {
        fmt.Println("This was definitely a database timeout.")
    }
}

Final Thoughts on Error Strategy

Good error handling isn’t just about making the code compile; it’s about making the system observable. When you wrap errors with %w, you create a trail of breadcrumbs that makes debugging production issues significantly easier. Instead of seeing a vague “unexpected EOF” in your logs, you’ll see “[USER_SERVICE] failed to fetch avatar: [S3_CLIENT] connection lost: unexpected EOF”.

Start by replacing your string comparisons with errors.Is and slowly introduce a custom error struct as your application grows. Your future self (and your teammates) will thank you when the first production incident occurs.

Share: