Go Error Handling
Wrapping and Inspecting Errors with Modern Go Features
Master the use of %w for adding context and use errors.Is and errors.As to traverse complex error chains.
In this article
The Architecture of Explicit Error Handling
Go takes a unique approach to failures by treating errors as values rather than control flow exceptions. This philosophy encourages developers to treat error paths with the same level of care as success paths, leading to more robust and predictable software. Instead of jumping to a catch block, your logic evaluates the returned value of a function to determine the next step.
In traditional error handling models, context is often lost as an error bubbles up the call stack. A low level network failure might eventually reach the user as a generic internal server error without any indication of what triggered it. Go solves this by allowing developers to decorate errors with additional information as they move through different layers of the application.
The introduction of error wrapping in Go 1.13 provided a standardized way to add this context while maintaining the identity of the original error. This creates a linked list of errors where each node adds a layer of semantic meaning to the failure. Understanding how to manage this chain is essential for building maintainable systems where diagnostic information is preserved.
When we talk about error chains, we are describing a structure where an outer error wraps an inner error. This allows a caller to inspect the high level failure while still being able to programmatically identify the root cause deep in the stack. This balance of context and identity is the cornerstone of effective Go error design.
Errors are not just strings for humans to read; they are stateful objects that guide the execution flow and diagnostic capabilities of your entire application.
The Evolution from Strings to Chains
Before modern wrapping conventions, developers often used string concatenation to add context to errors. This approach was problematic because it destroyed the original error type, making it impossible for calling code to react to specific failure conditions. For example, you could not easily check if a wrapped error was originally a timeout or a permission denied error.
By adopting the wrapping verb in formatting functions, the language runtime can now traverse these chains automatically. This allows utility functions to look through every layer of context to find a match for a specific error instance or type. It transforms the error from a static message into a searchable data structure.
Contextual Enrichment with the Wrapping Verb
The primary tool for building an error chain is the percent w formatting verb used within fmt.Errorf. Unlike the percent v verb which simply prints the string representation, percent w captures the underlying error value. This allows the new error to satisfy an internal interface that enables unwrapping.
Consider a scenario where a service attempts to fetch a user profile from a database. If the database connection fails, the repository layer should wrap that low level error with a message indicating the operation that failed. This ensures that the logs eventually show that the profile fetch failed because of a connection timeout, rather than just the timeout itself.
It is important to provide specific context that explains the intention of the code at the point of the wrap. Avoid generic messages like an error occurred and instead describe the action being performed. Good context includes parameters like user IDs, resource names, or the specific step in a multi stage process.
1package main
2
3import (
4 "errors"
5 "fmt"
6)
7
8var ErrDatabaseOffline = errors.New("database is currently unreachable")
9
10func FetchUser(userID string) error {
11 // Simulate a low-level failure
12 err := ErrDatabaseOffline
13 if err != nil {
14 // Use %w to wrap the original error and provide context
15 return fmt.Errorf("failed to retrieve user %s: %w", userID, err)
16 }
17 return nil
18}
19
20func main() {
21 err := FetchUser("user_12345")
22 if err != nil {
23 fmt.Println(err)
24 }
25}In the example above, the returned error contains both the human readable message and the programmatic identity of ErrDatabaseOffline. This allows the calling code to both log a helpful message and perform logic based on the specific type of failure. This pattern should be standard practice whenever an error crosses a package boundary.
One common pitfall is wrapping an error too many times within the same function or package. This can lead to redundant information and unnecessarily long error messages that clutter logs. You should generally wrap an error once at the point where you transition from one layer of logic to another, such as from the data access layer to the business logic layer.
Choosing Between Wrapping and Hiding
Wrapping is not always the correct choice for every error in your application. Sometimes you want to intentionally hide the underlying implementation details from the caller to maintain encapsulation. For instance, you might not want to expose that your service uses PostgreSQL if that information is not relevant to the API consumer.
If you use percent v instead of percent w, you create a new error that contains the text of the old one but does not allow unwrapping. This effectively breaks the chain and seals the error at that layer. Use this technique when you want to provide a clean boundary and prevent callers from depending on internal failure modes.
Introspection Using Sentinel Checks
Once you have a chain of wrapped errors, you need a way to inspect it without manually parsing strings. The errors.Is function is designed to traverse an error chain and check if any error in that chain matches a specific target. This is much more robust than direct equality checks which only look at the top level error.
This function works by repeatedly calling an Unwrap method on the error values until it finds a match or reaches the end of the chain. This means your code can handle a high level error from a service while still checking if it was ultimately caused by a specific sentinel error like a timeout or an EOF. This promotes loose coupling between layers of your application.
Using errors.Is is particularly useful for implementing retry logic or specialized recovery routines. For example, you might only want to retry a request if the underlying error is a temporary network interruption. By checking the chain, you ensure your retry logic works even if the network error was wrapped by three different layers of business logic.
1package main
2
3import (
4 "errors"
5 "fmt"
6 "net"
7)
8
9func ProcessOrder() error {
10 // Simulate a wrapped network error
11 innerErr := &net.DNSError{Name: "order-db", IsTemporary: true}
12 return fmt.Errorf("repository layer: %w", fmt.Errorf("service layer: %w", innerErr))
13}
14
15func main() {
16 err := ProcessOrder()
17
18 // Check if any error in the chain is a DNSError
19 var dnsErr *net.DNSError
20 if errors.As(err, &dnsErr) {
21 fmt.Printf("Recovering from network failure: %v\n", dnsErr.Name)
22 }
23
24 // Use errors.Is for simple sentinel comparisons
25 if errors.Is(err, net.ErrClosed) {
26 fmt.Println("Connection was closed")
27 }
28}The logic inside errors.Is handles pointer comparisons and respects custom Is methods that types may implement. This flexibility allows complex error types to define their own matching logic, which is useful when identity is not just based on a single instance but on certain properties. It ensures that the matching logic remains consistent regardless of how many layers of context are added.
Best Practices for Sentinel Errors
When defining your own sentinel errors, always start them with a prefix that matches the package name to avoid confusion. This makes it clear to consumers where the error originated when they see it in logs or documentation. Keep sentinel errors global and immutable so that they can be safely compared across different parts of the program.
Avoid creating too many sentinel errors for scenarios that can be handled by a single generic error type. Use sentinels for conditions that actually require different programmatic responses from the caller. If the response to two different errors is the same, consider if they should be consolidated into a single identity.
Pattern Matching for Complex Error Types
Sometimes you need to do more than just identify an error; you need to access specific fields or methods on a custom error type. This is where errors.As comes into play as it acts like a type assertion for the entire error chain. It looks for the first error in the chain that can be assigned to the provided target variable.
This is essential when working with sophisticated libraries that return detailed error metadata. For example, a database driver might return a custom error struct that includes the specific SQL state code or the constraint name that was violated. By using errors.As, you can safely extract this information even if the error was wrapped by your repository and service layers.
The target for errors.As must be a pointer to a type that implements the error interface. If a match is found, the function sets the target to the matching error value and returns true. If no match is found after traversing the entire chain, it returns false without modifying the target variable.
1package main
2
3import (
4 "errors"
5 "fmt"
6)
7
8type APIError struct {
9 StatusCode int
10 Message string
11}
12
13func (e *APIError) Error() string {
14 return fmt.Sprintf("api error [%d]: %s", e.StatusCode, e.Message)
15}
16
17func main() {
18 // A wrapped custom error
19 originalErr := &APIError{StatusCode: 404, Message: "User Not Found"}
20 wrappedErr := fmt.Errorf("gateway failure: %w", originalErr)
21
22 var target *APIError
23 if errors.As(wrappedErr, &target) {
24 // Now we can access specific fields of APIError
25 fmt.Printf("Handled %d error: %s\n", target.StatusCode, target.Message)
26 } else {
27 fmt.Println("Generic error occurred")
28 }
29}This approach avoids the fragility of manual type assertions on nested errors. Without errors.As, you would have to manually unwrap the error in a loop and perform a type assertion at each step, which is error prone and verbose. The standard library implementation handles these details for you, ensuring that you always find the correct error if it exists in the chain.
It is worth noting that errors.As will find the first error in the chain that matches the type. If you have multiple errors of the same type in a single chain, which is rare but possible, only the outermost one will be captured. This matches the standard expectation that the most recent context is usually the most relevant.
Safety and Type Correctness
One frequent mistake is passing a non pointer value to errors.As, which will cause a runtime panic. The function signature requires an interface, but the underlying implementation expects a pointer to a pointer or a pointer to an interface. Always ensure you are passing the address of your target variable to allow the function to perform the assignment.
Another consideration is ensuring that your custom error types correctly implement the error interface. If you define a struct but forget the Error method, it will not be compatible with the error type system. Consistency in implementation ensures that your custom types play nicely with the standard library introspection tools.
Strategic Tradeoffs in Error Propagation
While error wrapping is a powerful feature, it should be used judiciously to avoid creating bloated error chains. Every layer of wrapping adds a small amount of memory overhead and a step in the traversal process. In high performance hot paths, deep chains can theoretically impact performance, though this is rarely the bottleneck in real world applications.
A more significant concern is the leaking of implementation details across trust boundaries. When you wrap an error from a database driver and return it all the way to a public API handler, you might inadvertently expose internal schema names or infrastructure details. In these cases, it is better to map the internal error to a clean domain error and log the detailed wrapped error internally.
Consistency is key across a large codebase or team. Establish a convention for when to wrap and when to create new errors. A common pattern is to wrap at the boundary of a package but use simple sentinel returns for internal logic where the context is already implied by the scope of the function.
- Use %w when you want to allow callers to programmatically inspect the root cause of an error.
- Use %v when you want to provide context but protect the internal details of your package.
- Always check errors using Is and As instead of direct equality or type assertions to ensure chain compatibility.
- Provide meaningful context in wrap messages, including unique identifiers like order IDs or file paths.
- Map internal errors to well defined public errors at the edge of your application to prevent information leakage.
Ultimately, the goal of Go error handling is to make the failure modes of your system as visible and manageable as the success paths. By mastering the tools of wrapping and introspection, you create a system that is not only easier to debug but also more resilient to changes in underlying dependencies. Treat every error as an opportunity to provide better observability into your application state.
