Comprehensive Guide to Error Handling in Go: From Basics to Advanced Patterns
Error handling is a fundamental aspect of robust software development, and Go takes a distinctive approach that differs significantly from many other modern programming languages. Rather than using exceptions, Go embraces explicit error handling that promotes clarity and visibility, though at the cost of some verbosity. This guide explores the complete spectrum of error handling in Go, from basic patterns to advanced techniques that will help you write more robust, maintainable code.
Go’s Philosophy on Error Handling
Go’s approach to error handling is rooted in simplicity and explicitness. Unlike languages that use exceptions (like Java, Python, or C#), Go treats errors as values that must be explicitly checked and handled. This philosophy stems from the Go designers’ belief that:
- Explicit is better than implicit: When errors are values that must be checked, the error handling flow is always visible in the code.
- Simplicity matters: Complex error hierarchies and exception handling can make code harder to reason about.
- Control flow should be clear: The path of execution, including error cases, should be easy to follow.
While this approach can feel verbose initially, it offers significant benefits: code becomes more predictable, the “happy path” and error paths are equally visible, and there are no hidden control flows that might surprise maintainers later.
Basic Error Handling Patterns
The Standard Pattern: Check and Return
The most common error handling pattern in Go is checking errors immediately after function calls:
result, err := someFunction()
if err != nil {
// Handle the error
return nil, err
}
// Continue with the result
This pattern is so ubiquitous that it’s considered idiomatic Go. Always handle errors immediately—don’t let them linger.
Adding Context to Errors
When returning errors up the call stack, add context to help with debugging:
func readConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
return &config, nil
}
The %w verb (introduced in Go 1.13) wraps the original error, preserving its type and value while adding context. This allows error inspection with errors.Is and errors.As (which we’ll cover later).
Sentinel Errors
For specific error conditions that callers might want to check, define sentinel errors:
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrTimeout = errors.New("operation timed out")
)
func GetUser(id string) (*User, error) {
user, found := userDB[id]
if !found {
return nil, ErrNotFound
}
return user, nil
}
Callers can then check for specific errors:
user, err := GetUser(userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
// Handle not found specifically
return nil, fmt.Errorf("user %s not found", userID)
}
// Handle other errors
return nil, fmt.Errorf("failed to get user: %w", err)
}
Handling Multiple Error Types
For complex operations that can fail in different ways, consider using custom error types:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on field %s: %s", e.Field, e.Message)
}
func ValidateUser(user User) error {
if user.Name == "" {
return &ValidationError{
Field: "name",
Message: "name cannot be empty",
}
}
if user.Age < 0 {
return &ValidationError{
Field: "age",
Message: "age cannot be negative",
}
}
return nil
}
Then use type assertions or errors.As to handle specific error types:
if err := ValidateUser(user); err != nil {
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Validation failed: %s\n", valErr.Message)
return
}
fmt.Printf("Unknown error: %v\n", err)
}
Advanced Error Handling Techniques
Error Wrapping (Go 1.13+)
Go 1.13 introduced error wrapping with the %w formatting verb and related functions:
// Wrapping an error
err := doSomething()
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
This allows two powerful checks:
- errors.Is: Check if an error or any error it wraps matches a specific error value
// Check if err or any wrapped error is ErrNotFound
if errors.Is(err, ErrNotFound) {
// Handle not found case
}
- errors.As: Check if an error or any error it wraps matches a specific error type
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
line, col := findLineCol(data, syntaxErr.Offset)
fmt.Printf("Syntax error at line %d, column %d\n", line, col)
}
The Errors Package
For more advanced error handling with stack traces, consider using the github.com/pkg/errors package:
import "github.com/pkg/errors"
func readConfig() (*Config, error) {
data, err := ioutil.ReadFile("config.json")
if err != nil {
return nil, errors.Wrap(err, "reading config file")
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, errors.Wrap(err, "parsing config file")
}
return &config, nil
}
This package provides several useful functions:
errors.New: Create a new error with stack traceerrors.Wrap: Wrap an error with a message and stack traceerrors.Cause: Get the original error
However, with Go 1.13+’s error wrapping features, the standard library now covers most use cases.
Working with Multiple Errors
Sometimes you need to collect multiple errors before returning them. Here are a few approaches:
1. Using Slices
func validateForm(form Form) error {
var errs []error
if form.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if form.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if len(errs) > 0 {
return fmt.Errorf("form validation failed: %v", errs)
}
return nil
}
2. Using the errors package in Go 1.20+
Go 1.20 introduced errors.Join for combining multiple errors:
func validateForm(form Form) error {
var errs []error
if form.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if form.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
Errors combined with errors.Join can still be checked with errors.Is and errors.As.
3. Using Third-Party Packages
For more complex use cases, consider packages like github.com/hashicorp/go-multierror:
import "github.com/hashicorp/go-multierror"
func validateForm(form Form) error {
var result *multierror.Error
if form.Name == "" {
result = multierror.Append(result, errors.New("name is required"))
}
if form.Email == "" {
result = multierror.Append(result, errors.New("email is required"))
}
return result.ErrorOrNil()
}
Functional Options for Error Handling
For functions with many possible error conditions, you can use functional options to keep the code clean:
type errorOption func(*error)
func WithValidation(validate func() error) errorOption {
return func(err *error) {
if *err != nil {
return
}
*err = validate()
}
}
func WithTransaction(tx *sql.Tx) errorOption {
return func(err *error) {
if *err != nil {
tx.Rollback()
return
}
*err = tx.Commit()
}
}
func ProcessOrder(order Order, opts ...errorOption) error {
var err error
// Apply all error options
for _, opt := range opts {
opt(&err)
if err != nil {
return err
}
}
// Process the order if no errors occurred
return nil
}
// Usage
err := ProcessOrder(order,
WithValidation(func() error {
if order.Amount <= 0 {
return errors.New("order amount must be positive")
}
return nil
}),
WithTransaction(tx),
)
Practical Error Handling Patterns
HTTP Error Handling
For web applications, consistent error handling is crucial. Here’s a pattern that works well:
type ErrorResponse struct {
Status int `json:"status"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
func WriteError(w http.ResponseWriter, status int, message string, err error) {
resp := ErrorResponse{
Status: status,
Message: message,
}
if err != nil {
resp.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(resp)
}
// Usage in a handler
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
user, err := userService.GetUser(id)
if err != nil {
if errors.Is(err, ErrNotFound) {
WriteError(w, http.StatusNotFound, "User not found", err)
return
}
WriteError(w, http.StatusInternalServerError, "Failed to get user", err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
For more advanced HTTP error handling, middleware can be helpful:
func ErrorMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Create a custom response writer that captures the status code
crw := &customResponseWriter{ResponseWriter: w, status: http.StatusOK}
// Recover from panics
defer func() {
if rec := recover(); rec != nil {
err, ok := rec.(error)
if !ok {
err = fmt.Errorf("%v", rec)
}
stack := string(debug.Stack())
log.Printf("Panic: %v\n%s", err, stack)
if crw.status == http.StatusOK {
crw.status = http.StatusInternalServerError
}
WriteError(crw, crw.status, "Internal server error", err)
}
}()
// Call the next handler
next.ServeHTTP(crw, r)
})
}
Database Error Handling
Database operations have specific error patterns:
func GetUser(ctx context.Context, id string) (*User, error) {
var user User
err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = ?", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %s not found: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("database error: %w", err)
}
return &user, nil
}
API Client Error Handling
When writing clients for external APIs, structure your error handling for clear diagnostics:
type APIError struct {
StatusCode int
URL string
Message string
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error [%d] on %s: %s", e.StatusCode, e.URL, e.Message)
}
func (c *Client) Get(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return &APIError{
StatusCode: resp.StatusCode,
URL: url,
Message: resp.Status,
Body: string(body),
}
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
return nil
}
Error Handling in Concurrent Code
Error handling in goroutines requires special consideration since you can’t simply return an error from a goroutine.
Using Error Channels
func processItems(items []Item) error {
errs := make(chan error, len(items))
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
if err := processItem(item); err != nil {
errs <- err
}
}(item)
}
// Wait for all goroutines to complete
wg.Wait()
close(errs)
// Collect errors
var errList []error
for err := range errs {
errList = append(errList, err)
}
if len(errList) > 0 {
return errors.Join(errList...)
}
return nil
}
Using errgroup
The golang.org/x/sync/errgroup package provides a cleaner way to handle errors in concurrent code:
import "golang.org/x/sync/errgroup"
func processItems(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
item := item // Create a new variable to avoid closure capture issues
g.Go(func() error {
return processItem(ctx, item)
})
}
// Wait for all goroutines to complete or return on first error
if err := g.Wait(); err != nil {
return fmt.Errorf("processing items: %w", err)
}
return nil
}
The errgroup package stops all goroutines as soon as one returns an error, using the provided context.
Handling Panics
While Go’s error handling is explicit, panics can still occur for truly exceptional situations. It’s important to handle them gracefully:
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
err, ok := rec.(error)
if !ok {
err = fmt.Errorf("%v", rec)
}
stack := string(debug.Stack())
log.Printf("Panic: %v\n%s", err, stack)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
For background jobs or critical goroutines:
func SafeGoroutine(f func()) {
go func() {
defer func() {
if rec := recover(); rec != nil {
err, ok := rec.(error)
if !ok {
err = fmt.Errorf("%v", rec)
}
stack := string(debug.Stack())
log.Printf("Panic in goroutine: %v\n%s", err, stack)
}
}()
f()
}()
}
// Usage
SafeGoroutine(func() {
// Do some work that might panic
})
Best Practices for Go Error Handling
Based on the patterns we’ve covered, here are some best practices for error handling in Go:
Be Explicit: Always check and handle errors explicitly. Don’t ignore them with
_.Add Context: When returning errors, add context with
fmt.Errorf("operation failed: %w", err).Use Sentinel Errors for specific error conditions that callers might need to check.
Create Custom Error Types for complex error scenarios that need additional information.
Use
errors.Isanderrors.Asfor checking wrapped errors, not direct equality (==).Handle Errors Once: Handle each error in exactly one place—either log it or return it, not both.
Don’t Panic: Treat
panicas a last resort for truly unrecoverable situations.Create Helper Functions for common error handling patterns to reduce boilerplate.
Use Middleware for consistent error handling across HTTP handlers.
Test Error Paths: Ensure you have test coverage for error conditions, not just happy paths.
Common Error Handling Anti-Patterns to Avoid
While we’ve covered many good practices, here are some anti-patterns to avoid:
Ignoring Errors: Don’t discard errors with
_unless you have a specific reason.// Bad file, _ := os.Open("file.txt") // Good file, err := os.Open("file.txt") if err != nil { // Handle error }Shadowing Errors: Don’t declare new variables with the same name in if blocks.
// Bad - err is redeclared in the if scope if err := doSomething(); err != nil { // This err shadows the outer err if err := doSomethingElse(); err != nil { return err // Only returns the inner error } } // Good if err := doSomething(); err != nil { if innerErr := doSomethingElse(); innerErr != nil { return fmt.Errorf("nested error: %w", innerErr) } return err }Using Panics for Normal Flow Control: Panics should be reserved for truly exceptional cases.
// Bad func getUser(id string) *User { user, found := userDB[id] if !found { panic("user not found") } return user } // Good func getUser(id string) (*User, error) { user, found := userDB[id] if !found { return nil, ErrNotFound } return user, nil }Excessive Wrapping: Don’t add too many layers of wrapping to errors.
// Bad - too many layers if err := doSomething(); err != nil { return fmt.Errorf("layer1: %w", fmt.Errorf("layer2: %w", fmt.Errorf("layer3: %w", err))) } // Good - one meaningful layer is enough if err := doSomething(); err != nil { return fmt.Errorf("operation failed: %w", err) }Both Logging and Returning: Don’t both log and return the same error.
// Bad - double handling if err := doSomething(); err != nil { log.Printf("Failed: %v", err) return err } // Good - either log and handle or return if err := doSomething(); err != nil { // Handle locally log.Printf("Failed: %v", err) // Return a different error or nil return nil } // Or if err := doSomething(); err != nil { // Add context and propagate up return fmt.Errorf("operation failed: %w", err) }
Conclusion
Go’s approach to error handling reflects its philosophy of simplicity and explicitness. While it may seem verbose initially, explicit error handling prevents surprises, improves code clarity, and forces developers to think about error conditions upfront.
As you’ve seen from the patterns in this guide, there are many ways to make error handling in Go more efficient and expressive without sacrificing these principles. By embracing Go’s error handling model and applying these patterns, you can write code that is both robust and maintainable.
Remember, good error handling isn’t just about catching failures—it’s about providing clear information that helps users and developers understand what went wrong and how to fix it. In Go, errors are just values, but with the right approaches, they can be powerful tools for building reliable software.