Effective Error Handling in Go: Wrapping, Sentinels, and Custom Types
Error handling in Go is straightforward, explicit, and forces developers to deal with failure states right at the point of origin. While if err != nil is a running joke in the community, when used correctly, Go’s error handling produces robust, debuggable, and maintainable software.
In my Go projects, I adhere to a set of practices that ensure errors are not just checked, but are rich with context and actionable. Let’s dive into how I handle errors effectively.
1. Sentinel Errors: The Bedrock of Predictability
Sentinel errors are predefined error variables that indicate a specific, expected failure condition. They act as “sentinels” that your code can watch out for.
1package user
2
3import "errors"
4
5// Sentinel errors
6var (
7 ErrNotFound = errors.New("user not found")
8 ErrInvalidEmail = errors.New("invalid email format")
9 ErrDatabaseConnection = errors.New("database connection failed")
10)
Using sentinel errors allows callers to confidently check for specific failures without parsing error strings (which is brittle and prone to breaking on typos or refactors).
2. Wrapping Errors: Adding Crucial Context
When an error occurs deep within a call stack, simply returning it up the chain strips away valuable context. When the error finally surfaces, it might just say "no such file or directory", leaving you wondering which file and why it was being accessed.
Go 1.13 introduced error wrapping using the %w verb in fmt.Errorf, and Go 1.20 added the ability to use multiple %w verbs in a single fmt.Errorf call. This is crucial for building an informative trail.
The Rule of Thumb: Wrap Generously
Whenever you pass an error up the stack, wrap it with context about what you were trying to do.
1func processUserFile(filename string) error {
2 file, err := os.Open(filename)
3 if err != nil {
4 // We wrap the sentinel/os error and provide details using multiple %w verbs
5 // Note: os.Open's error often contains the filename already, but wrapping
6 // ensures we capture the context if the failure happens elsewhere (like read/close).
7 return fmt.Errorf("failed to open user config %w: %w", ErrNotFound, err)
8 }
9 defer file.Close()
10
11 // ...
12 return nil
13}
Notice the pattern here: fmt.Errorf("(details if necessary) %w: %w", sentinel error, nested error).
Desirable Consequences:
- Debuggability: When an error is logged at the top level, it prints a complete sentence explaining the failure chain:
"failed to open user config user not found: open /etc/config.json: no such file or directory". - Traceability: You can see exactly which layers of your application the error passed through.
Tip: Er on the side of adding more context rather than less.
3. Extracting and Checking: errors.Is and errors.As
Because we are heavily wrapping errors, we can no longer simply use equality (err == ErrNotFound) to check for sentinel errors. The original error is buried inside layers of fmt.Errorf.
This is where errors.Is and errors.As come in.
errors.Is for Sentinels
Use errors.Is to check if a specific sentinel error exists anywhere in the chain.
1err := processUserFile("missing.json")
2if errors.Is(err, ErrNotFound) {
3 // Handle the specific 'not found' case
4 fmt.Println("Looks like the file is missing, falling back to defaults.")
5}
You can also use a switch statement when checking against multiple specific sentinels. This approach scales beautifully, especially when handling special cases like io.EOF:
1err := performAction()
2switch {
3case errors.Is(err, io.EOF):
4 // EOF often requires different handling, such as silently returning
5 // rather than treating it as a true error.
6 return nil
7case errors.Is(err, ErrNotFound):
8 return handleNotFound()
9case err != nil:
10 // A catch-all for any other error
11 return fmt.Errorf("unexpected error occurred: %w", err)
12}
errors.As for Custom Types
Use errors.As when you need to extract a specific type of error from the chain to access its fields.
1var pathErr *os.PathError
2if errors.As(err, &pathErr) {
3 fmt.Println("Failed operation:", pathErr.Op)
4 fmt.Println("Failed path:", pathErr.Path)
5}
4. Custom Error Types for HTTP Handling
Sometimes, simple string-based errors aren’t enough. When dealing with HTTP servers, you often need to attach HTTP status codes and user-facing messages to your internal errors.
Creating custom error types is highly effective here.
1package httperr
2
3import "fmt"
4
5// UserError represents an error that can be safely returned to a client
6type UserError struct {
7 StatusCode int
8 Message string
9 Err error // The underlying internal error
10}
11
12func (e *UserError) Error() string {
13 if e.Err != nil {
14 return fmt.Sprintf("HTTP %d - %s: %v", e.StatusCode, e.Message, e.Err)
15 }
16 return fmt.Sprintf("HTTP %d - %s", e.StatusCode, e.Message)
17}
18
19// Unwrap allows errors.Is and errors.As to work with the wrapped error.
20// If multiple errors were wrapped, Go 1.20+ supports returning a slice: func (e *UserError) Unwrap() []error
21func (e *UserError) Unwrap() error {
22 return e.Err
23}
24
25// Helper for easy creation
26func NewUserError(status int, msg string, err error) error {
27 return &UserError{
28 StatusCode: status,
29 Message: msg,
30 Err: err,
31 }
32}
Using Custom Errors in Handlers
In your HTTP handlers or middleware, you can inspect the error chain to see if a UserError was returned. If it was, you extract the status code and message. If not, you default to a safe 500 Internal Server Error, ensuring internal implementation details don’t leak to the client.
1func myHandler(w http.ResponseWriter, r *http.Request) {
2 err := performAction()
3 if err != nil {
4 handleHTTPError(w, err)
5 return
6 }
7 w.WriteHeader(http.StatusOK)
8}
9
10func handleHTTPError(w http.ResponseWriter, err error) {
11 var uErr *UserError
12
13 // Check if the error chain contains a UserError
14 if errors.As(err, &uErr) {
15 // We found a UserError, safely return its details
16 http.Error(w, uErr.Message, uErr.StatusCode)
17 // Log the internal error for debugging
18 log.Printf("Request failed: %v", uErr.Err)
19 return
20 }
21
22 // Fallback: If it's not a UserError, it's an unexpected internal error
23 log.Printf("Internal Server Error: %v", err)
24 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
25}
5. When Not To Use Errors: Application Outcomes vs. Unexpected Faults
While robust error handling is critical, it is equally important to recognize when not to use errors, or at least how to categorize them properly to avoid unnecessary noise like stack traces or panics.
Application Outcomes Are Not Always Errors
If you are building an application designed to informatively exit successfully or unsuccessfully—such as a linter, a validation tool, or a sanity checking sort of app used in a script—a failure in validation is often an expected outcome, not a system error. While returning an error to represent these states is common in Go, treating them as exceptional system failures is what we want to avoid.
Technically, a linter finding a violation is an ApplicationOutcome, not an ApplicationOutcomeError. There was no system failure, so there is absolutely no reason to create a stack trace. Structuring your program to return an outcome result rather than an error for these expected states keeps your application logic clean and prevents expected failures from being treated as catastrophic system crashes.
User Errors vs. Unexpected Outcomes
Similarly, we must distinguish between errors caused by the user (like providing invalid input) and truly unexpected system faults.
User-caused errors do not need a stack trace. They should be handled gracefully using custom types, much like the UserError discussed above. When encountering a user error, you should inform the user of what they did wrong and exit cleanly, without causing a panic (e.g., never use log.Panic for invalid input).
Stack trace logs and panics should be strictly limited to unexpected outcomes—situations where the system enters an invalid state and we cannot immediately pinpoint the blame to user input. By reserving stack traces for actual bugs, you ensure that your logs remain actionable and aren’t cluttered with user mistakes.
Conclusion
By defining clear sentinel errors, generously wrapping errors with contextual information, and leveraging errors.Is, errors.As, and custom error types, you transform Go’s error handling from a tedious chore into a powerful tool for building resilient systems. It takes slightly more typing up front, but pays massive dividends when debugging production issues at 3 AM.