Quizzr Logo

Go Error Handling

Building Custom Error Types for Richer Application Context

Implement the error interface with custom structs to pass structured diagnostic data across your system's layers.

ProgrammingIntermediate16 min read

The Evolution of Error Representation

Go treats error handling as a deliberate flow of control rather than an exceptional interruption. By treating errors as values, the language encourages developers to inspect and react to failure states as part of the primary business logic. This explicit approach prevents many of the hidden side effects common in languages that rely on try-catch blocks for exception handling.

In simple applications, the built-in error interface is often satisfied by a basic string. While this is sufficient for logging a failure message to a console, it falls short in complex systems where the calling function needs to programmatically decide how to recover. A raw string lacks the metadata required for intelligent retry logic or granular HTTP status code mapping.

Errors in Go are not just strings for humans to read; they are state-carrying objects that guide your application's recovery strategy.

The shift toward custom error structs allows you to attach relevant context like request IDs, timestamps, or original database codes. This structured data transforms a vague failure into a diagnostic asset that can be passed across service boundaries. By defining your own error types, you establish a contract that consumers can rely on for more than just human-readable messages.

Limitations of Opaque Errors

Opaque errors are those returned as a simple interface without an underlying concrete type that can be easily inspected. When you use the standard error constructor, you are creating a value that only satisfies the error interface via a string representation. This forces consumers to use string matching if they need to determine the specific cause of an issue.

String matching is fragile because any change to the error message text in a library will break the consuming code logic. It also makes it impossible to extract numeric status codes or resource identifiers without complex parsing. Custom structs solve this by separating the message for the user from the data for the machine.

Designing for Diagnostic Depth

Effective error design starts with identifying the data points required by the caller to perform a recovery action. For instance, a database query failure might need to communicate whether the issue was a constraint violation or a connection timeout. Each of these scenarios requires a different response from the application layer.

By embedding specific metadata into your error types, you provide a roadmap for the rest of the system. This allows the API layer to return a 409 Conflict status for duplicate entries while returning a 503 Service Unavailable for timeout issues. The goal is to move beyond mere notification and toward actionable intelligence.

Implementing Custom Error Structures

To implement a custom error in Go, you define a struct that holds your diagnostic fields and then satisfy the error interface. The interface itself is minimal, requiring only a single method that returns a string. This simplicity is powerful because it allows any custom type to masquerade as a standard error while carrying extra weight.

The naming convention for these types usually follows the pattern of appending Error to the struct name, such as QueryError or ValidationError. This clarity helps other developers identify the purpose of the struct immediately when browsing the codebase. It also distinguishes the error type from the standard domain models used in your business logic.

goDefining a Structured Database Error
1package persistence
2
3import "fmt"
4
5// QueryError captures structural data about a database failure
6type QueryError struct {
7    Operation string
8    Table     string
9    Query     string
10    Err       error
11}
12
13// Error satisfies the error interface for QueryError
14func (e *QueryError) Error() string {
15    return fmt.Sprintf("database error during %s on %s: %v", e.Operation, e.Table, e.Err)
16}

In the example above, the struct captures the operation name and the target table along with the underlying error. This allows a central logger to record the specific SQL query that failed without exposing that sensitive information to the end user. The Error method maintains a clean summary while the fields remain accessible for logic.

Satisfying the Error Interface

The error interface is defined by the language as a single method named Error that returns a string value. When you define this method on your struct, you should generally use a pointer receiver if the struct is large or if you want to maintain consistency across the codebase. Using a pointer receiver ensures that your custom error value can be correctly compared during type assertions.

It is important to ensure that the Error method itself never returns a nil value or panics. Its primary job is to provide a sensible human-readable fallback when the structured data is not being explicitly used. A well-formatted string in this method helps tremendously when debugging stack traces in logs.

Handling Nested Failures

Most custom errors should include an internal field to store the original error that triggered the failure. This pattern is known as error wrapping and is essential for preserving the root cause as the error moves up the call stack. Without this link, you lose the technical details provided by low-level drivers like the SQL or network packages.

By convention, this field is often named Err and is typed as the standard error interface. This allows you to nest errors indefinitely, creating a chain of context that describes exactly how a high-level request eventually failed at the hardware or network level. It bridges the gap between high-level intent and low-level execution.

Extracting Machine-Readable Data

Once a custom error is returned, the caller needs a way to access the fields hidden behind the error interface. Go provides two primary mechanisms for this: type assertions and the errors package's specialized functions. Using these tools allows your code to branch logic based on the concrete type of the error received.

Modern Go development favors the errors.As and errors.Is functions introduced in version 1.13 over manual type assertions. These functions are designed to work with wrapped errors, searching through the entire chain of failures until a match is found. This ensures that your logic remains robust even if an intermediate layer wraps your custom error in additional context.

goProgrammatic Error Inspection
1func ProcessPayment(amount int) error {
2    err := gateway.Charge(amount)
3    if err != nil {
4        var gateErr *gateway.PaymentError
5        // Use errors.As to check if the error is a PaymentError
6        if errors.As(err, &gateErr) {
7            if gateErr.CanRetry {
8                return scheduleRetry(gateErr)
9            }
10            return fmt.Errorf("permanent payment failure: %w", err)
11        }
12        return fmt.Errorf("unknown failure: %w", err)
13    }
14    return nil
15}

This approach allows for highly specific recovery strategies without cluttering the main logic with complex string parsing. The calling code can check for a CanRetry boolean or an internal error code to decide if it should notify the user or try again. It creates a clean separation between error detection and error handling strategy.

The Power of errors.As

The errors.As function is the standard way to cast an error interface to a specific concrete pointer. It takes the error you received and a pointer to a variable of the type you are looking for. If the error matches the type, the function returns true and populates your variable with the data.

This mechanism is recursive, meaning it will look inside wrapped errors automatically. This is a significant advantage over manual type switches, which only look at the top-level error and ignore any nested context. It promotes a design where errors can be enriched without breaking the ability of upstream callers to inspect the original failure.

Comparing with Sentinel Errors

Sentinel errors are predefined error variables that represent a specific state, such as io.EOF or sql.ErrNoRows. While custom structs provide dynamic data, sentinels are best for static states that never change. You should use the errors.Is function to check for these specific instances when no extra context is needed.

  • Use custom structs when you need to pass dynamic data like IDs or retry durations.
  • Use sentinel errors for fixed states that require simple equality checks.
  • Prefer errors.As for structs and errors.Is for sentinel variables to support wrapping.

Architectural Patterns for Error Flow

In a tiered architecture, errors typically flow from the infrastructure layer through the domain layer up to the presentation layer. Each layer should decide whether to add more context to the error or transform it into a different type. Custom structs are particularly useful at the boundaries between these layers to translate technical failures into business failures.

For example, a repository layer might catch a raw database driver error and wrap it in a custom DataAccessError struct. This struct can strip away implementation details like SQL syntax but preserve the fact that a unique constraint was violated. This prevents leaking database internals to the API layer while still providing enough information for a 400 Bad Request response.

goLayered Error Translation
1func (s *UserService) Register(u User) error {
2    err := s.repo.Save(u)
3    if err != nil {
4        // Wrap with domain context while keeping the original error chain
5        return &DomainError{
6            Entity: "User",
7            ID:     u.Email,
8            Err:    err,
9            Code:   CodeAlreadyExists,
10        }
11    }
12    return nil
13}

This pattern creates a consistent diagnostic experience across the entire application stack. By the time an error reaches your top-level middleware, it contains a rich history of where it originated and why it happened. This makes logging and monitoring much more effective as you can extract specific fields for metrics.

Error Wrapping with Verbosity

When using custom structs, you should utilize the %w verb in your formatting functions to ensure compatibility with the standard library's unwrapping logic. This allows you to provide a high-level summary while still allowing the errors package to traverse the chain. Failing to wrap correctly can lead to opaque errors that hide the underlying cause from the caller.

Always consider what information is safe to expose to the caller versus what should stay in the logs. Custom error structs allow you to satisfy this by having private fields for internal use and a public Error method for the user. This duality is one of the strongest arguments for using structs over simple strings.

Avoiding Global Error State

One common pitfall is creating large global variables to hold error state across a package. While this might seem convenient, it makes testing difficult and can lead to race conditions if the error values are modified at runtime. Instead, instantiate your custom error structs at the point of failure with the specific context of that execution.

Treating errors as short-lived, immutable data points ensures that your error handling logic is thread-safe and predictable. Each error instance should represent a single point in time and a single set of conditions. This level of isolation is critical for debugging concurrent processes in high-performance Go services.

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.