Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 21

Custom Error Handling with Wrapping and Unwrapping

Recommended resources

Hello! Welcome back to our advanced Go tutorial.

In our last session, we achieved a significant milestone in system design: we made our storage layer completely swappable. By using an interface and some configuration in our main function, we can now switch between a real PostgreSQL database and a fast in-memory store without changing a single line of our API's business logic.

Your Goal for Today

Learning Outcome: Define and use custom error types with wrapping and unwrapping for domain-specific errors.

So far, our error handling has been functional but basic. If our database returns an error, we mostly just log it and pass it up the call stack. This creates a "leaky abstraction"—our service layer might end up dealing with database-specific errors like pgx.ErrNoRows. This couples our application logic to our database implementation, which undermines the very abstraction we just worked so hard to build.

Today, we'll fix that. You will learn to define your own domain-specific errors and use Go's powerful error handling features (wrapping and unwrapping) to translate low-level errors into meaningful, abstract errors that are part of your application's defined contract.



1. The Problem: Leaky Abstractions and Brittle Code

Imagine our PostgresStorage.Get() method tries to find an item that doesn't exist. The pgx driver will correctly return a pgx.ErrNoRows error. If our API handler receives this specific error, it might be tempted to check for it:

// In an API handler...
item, err := s.storage.Get(id)
if err == pgx.ErrNoRows { // <-- PROBLEM!
    // return HTTP 404 Not Found
}

The problem is that our API handler now has a direct dependency on the pgx library. What happens if we swap to the InMemoryStorage? It won't return pgx.ErrNoRows. Or what if we switch to a MySQL database with a different driver? The code breaks.

Our goal is to create a clear boundary. The repository is responsible for talking to the database; the rest of the application should only talk to the repository in terms of abstract, domain-specific concepts, including errors.

This diagram illustrates the concept of abstracting away the root cause of an error. We want our API to respond to a well-defined "Application Error" (like "Not Found"), not the specific "Root Cause" (a database error).

Error Wrapping and Abstraction Layers
This visual shows how a low-level database error is progressively wrapped or translated into higher-level application errors. Our goal is to control this translation process.


2. The Go 1.13+ Error Handling Toolkit

Since Go 1.13, the standard library provides a suite of tools for handling errors in a structured way. Before we apply them, let's understand the key concepts.

Working with Errors in Go 1.13

The official Go blog post 'Working with Errors in Go 1.13' is the definitive source on this topic. It introduces the new functions and conventions that have become standard practice in modern Go.

Please read the following sections to get a solid grasp of the core mechanics: The Unwrap method: Understand the convention of an Unwrap() error method to create a chain of errors. Examining errors with Is and As: Learn how errors.Is checks for specific error values (sentinels) and errors.As checks for specific error types within the chain. Wrapping errors with %w: See how the %w format verb in fmt.Errorf is the primary way to create wrapped errors.

Key Summary:

  • Wrapping (%w): fmt.Errorf("operation failed: %w", originalErr) creates a new error that contains the originalErr. This forms a chain.
  • Inspecting with errors.Is: This function walks the error chain to see if any error in it is equal to a target error value. It's perfect for checking for specific, predefined errors (called "sentinel errors").
  • Inspecting with errors.As: This function walks the error chain to see if any error can be assigned to a variable of a target type. It's used for custom error structs where you need to extract more data from the error.


3. Defining and Using a Domain-Specific Sentinel Error

Let's apply this by creating our first domain-specific error. The most common one is a "not found" error. We'll define this as a sentinel error: a package-level variable that represents a specific, known error condition.

Step 1: Define the Error

In your internal/storage package (e.g., in a new storage/errors.go file or at the top of your storage/storage.go), define the sentinel error.

// internal/storage/storage.go or a new errors.go

package storage

import "errors"

// ErrNotFound is returned when a requested item is not found.
var ErrNotFound = errors.New("not found")

// ... (rest of your Storage interface)

This ErrNotFound is now part of the public API of your storage package. Any code using the Storage interface can reliably check for this specific error.

The video below explains this pattern of using sentinel errors clearly.

The secret to making Golang error handling a breeze

The video 'The secret to making Golang error handling a breeze' from Earthly provides a great explanation of sentinel errors and how errors.Is is used to check for them.

Watch from 03:27 to 04:29. This segment demonstrates declaring a sentinel error (errorDivideByZero) and then using errors.Is in the calling code to check for that specific error condition.

Step 2: Translate the Error in the Repository

Now, let's modify PostgresStorage to use this new error. The goal is to catch pgx.ErrNoRows and return our own storage.ErrNotFound instead.

Find your Get method in internal/storage/postgres.go (or wherever you defined your PostgresStorage).

// In your PostgresStorage implementation...

import (
    "errors" // Make sure errors package is imported
    "fmt"
    "github.com/jackc/pgx/v5" // And pgx
)

// In your Get method (or whichever method fetches a single item)
func (p *PostgresStorage) Get(id string) (*Item, error) {
    // ... your query logic
    err := p.db.QueryRow(ctx, query, id).Scan(...)
    if err != nil {
        // Check if the error is the specific 'no rows' error from pgx
        if errors.Is(err, pgx.ErrNoRows) {
            // If it is, return our domain-specific ErrNotFound.
            // DO NOT wrap it. We are translating the error.
            return nil, ErrNotFound
        }
        // For any other error, wrap it to provide context.
        return nil, fmt.Errorf("failed to get item with id %s: %w", id, err)
    }
    // ...
    return &item, nil
}

This is the core of the pattern. We inspect the low-level error. If it's a known condition we want to abstract (pgx.ErrNoRows), we return our clean, domain-specific ErrNotFound. For all other unexpected database errors, we wrap them to add context for logging and debugging, without exposing their type.

The dev.to article "Mastering Data Access in Go" provides an excellent, real-world example of this exact translation.

Mastering Data Access in Go with Repositories & sqlc (2) - Dev.to

This article provides a superb, practical implementation of the pattern we're building. It shows how to map pgx.ErrNoRows to a domain-specific ErrUserNotFound.

Find the code block for the pgxUserRepository implementation. Look inside the GetByEmail and GetByID functions. Notice the if errors.Is(err, pgx.ErrNoRows) check, which then returns ErrUserNotFound. This is precisely what we have just done.



4. Advanced Case: Custom Error Structs

Sentinel errors are great for simple conditions. But what if you need to convey more information? For instance, if a user tries to create an item with a key that already exists, you might want the error to include that key. For this, we can define a custom struct that implements the error interface.

Let's define an ErrDuplicate error.

Step 1: Define the Custom Error Struct

In the same place you defined ErrNotFound, add the following:

// internal/storage/storage.go or errors.go

import "fmt"

// ErrDuplicate is returned when an item with the same key already exists.
type ErrDuplicate struct {
    Key string
}

// Error implements the error interface.
func (e *ErrDuplicate) Error() string {
    return fmt.Sprintf("item with key %q already exists", e.Key)
}

Now we have an error type that carries structured data.

Step 2: Use the Custom Error

In a hypothetical Create method within PostgresStorage, you could check for a unique constraint violation from PostgreSQL and translate it to your ErrDuplicate. PostgreSQL's error code for a unique violation is 23505.

// In a hypothetical PostgresStorage.Create method...

import (
    "github.com/jackc/pgx/v5/pgconn"
)

func (p *PostgresStorage) Create(item *Item) error {
    query := `INSERT INTO items (id, data) VALUES ($1, $2)`
    _, err := p.db.Exec(ctx, query, item.ID, item.Data)
    if err != nil {
        var pgErr *pgconn.PgError
        // Use errors.As to check if the error is a postgres error
        if errors.As(err, &pgErr) {
            // If it is, check the specific error code
            if pgErr.Code == "23505" { // Unique violation
                return &ErrDuplicate{Key: item.ID}
            }
        }
        // For other errors, wrap as before
        return fmt.Errorf("failed to create item: %w", err)
    }
    return nil
}

Step 3: Inspecting with errors.As

In the calling code (e.g., an API handler), you can now check for this specific error type and extract its data.

// In an API handler...
err := s.storage.Create(newItem)
if err != nil {
    var dupErr *ErrDuplicate
    // Use errors.As to see if the error is our duplicate error type
    if errors.As(err, &dupErr) {
        // It is! We can now access the Key field.
        // Return HTTP 409 Conflict with a detailed message.
        return fmt.Errorf("conflict: item %s already present", dupErr.Key) 
    }
    // Handle other errors, like our ErrNotFound, or generic 500s
}

This pattern of creating a custom struct, using errors.As, and checking for specific driver error codes is demonstrated perfectly in the resources.

Handling errors LIKE a 10x ENGINEER in Golang - Golang Service Pattern

The video 'Handling errors LIKE a 10x ENGINEER' shows a very similar pattern for creating a generic service error that wraps an application-specific error. It clearly demonstrates defining a custom error struct and using errors.As to inspect it.

First, watch from 02:27 to 03:37 to see how a custom error struct is defined to hold multiple underlying errors. Then, watch from 04:37 to 05:21 to see how errors.As is used to 'cast' the error back to the custom type to access its internal fields.



5. API Design Philosophy: To Wrap or Not to Wrap?

This is a critical point for experienced developers. When you encounter an error from a dependency, you have two choices:

  1. Translate it (Don't Wrap): Like we did with pgx.ErrNoRows. We caught a specific error and returned our own storage.ErrNotFound. We intentionally hid the original pgx error.
  2. Wrap it (%w): Like we did for unexpected errors: fmt.Errorf("...: %w", err). We preserved the original error for debugging but added our own context.

The rule of thumb is: Wrapping an error makes it part of your package's public API.

If you wrap pgx.ErrNoRows, you are giving your callers the ability to use errors.Is(err, pgx.ErrNoRows) and create a dependency on pgx. You should only do this if you are willing to commit to that dependency forever. By translating the error instead, you preserve your abstraction boundary.

Working with Errors in Go 1.13

This is perhaps the most important concept in this lesson for system design. The official Go blog explains the API design implications of error wrapping with exceptional clarity.

Please read the section titled 'Whether to Wrap' very carefully. It explains why exposing internal errors (like sql.ErrNoRows in their example) by wrapping them can be a violation of abstraction.



Conclusion

Excellent work today. You've moved beyond basic error handling and have started to treat errors as a first-class part of your API design. This is a hallmark of robust, maintainable, and well-architected Go services.

Key Takeaways:

  • Avoid "leaky abstractions" where implementation-specific errors (like pgx.ErrNoRows) bubble up past your repository layer.
  • Define domain-specific errors (like ErrNotFound) as part of your package's public contract. These can be sentinel errors (var Err...) or custom structs.
  • Use errors.Is to check for sentinel errors and errors.As to check for and extract data from custom error types.
  • Make a conscious decision whether to translate an error (hiding the original) or to wrap it with %w (exposing the original as part of your API).

Next Up

With our application now featuring a flexible, swappable backend and robust error handling, we are ready to tackle more production-oriented concerns. In the next lesson, we will begin Module 4 by exploring context propagation for managing request lifecycles, timeouts, and cancellations throughout our application.