Idiomatic Error Handling in Go: Wrapping, errors.Is & errors.As ⚠️

Go handles errors differently from languages with exceptions (try/catch). In Go, errors are normal values returned explicitly from functions.

Since Go 1.13, Go provides standardized Error Wrapping (%w), errors.Is(), and errors.As(), allowing you to wrap errors with rich contextual messages while preserving the ability to inspect underlying error causes.


1. The Basics: Creating and Returning Errors

In Go, error is a simple built-in interface with a single method:

type error interface {
    Error() string
}
package main
 
import (
	"errors"
	"fmt"
)
 
// Sentinel Error (reusable package-level error)
var ErrUserNotFound = errors.New("user not found")
 
func findUser(id int64) (string, error) {
	if id <= 0 {
		return "", errors.New("invalid user ID: must be positive")
	}
	if id != 42 {
		return "", ErrUserNotFound
	}
	return "Alice", nil
}
 
func main() {
	user, err := findUser(99)
	if err != nil {
		fmt.Printf("Lookup failed: %v\n", err)
		return
	}
	fmt.Println("Found:", user)
}

2. Error Wrapping with fmt.Errorf and %w

When returning an error up the call stack, always add context describing what your function was trying to do. Use the %w verb to wrap the original error:

func parseConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		// Wraps the underlying os.PathError with custom context!
		return nil, fmt.Errorf("parseConfig failed for %s: %w", path, err)
	}
	// ... unmarshal config ...
	return cfg, nil
}

If os.ReadFile fails, the output will look like: parseConfig failed for /etc/app.yaml: open /etc/app.yaml: no such file or directory


3. Inspecting Wrapped Errors: errors.Is() and errors.As()

Because errors can be wrapped multiple times, you should never compare errors with == or parse error strings with strings.Contains.

1. errors.Is() (Checking for Specific Sentinel Errors)

errors.Is(err, target) unwraps the chain of errors to see if target exists anywhere inside:

package main
 
import (
	"errors"
	"fmt"
	"io"
)
 
var ErrDatabaseDown = errors.New("database connection refused")
 
func queryOrders() error {
	// Simulated error wrapped inside another error
	return fmt.Errorf("service layer: %w", ErrDatabaseDown)
}
 
func main() {
	err := queryOrders()
 
	// ❌ Fragile check:
	// if err == ErrDatabaseDown { ... } // Fails because err is wrapped!
 
	// ✅ Idiomatic check: unwraps chain!
	if errors.Is(err, ErrDatabaseDown) {
		fmt.Println("Handling DB outage: retrying with backoff...")
	}
}

2. errors.As() (Extracting Specific Error Types)

When an error has structured fields (like an HTTP status code or path), errors.As(err, &target) unwraps the chain and populates the target variable:

package main
 
import (
	"errors"
	"fmt"
	"os"
)
 
func main() {
	_, err := os.Open("/non-existent-file.txt")
	if err != nil {
		var pathErr *os.PathError
		// Unwraps and extracts *os.PathError if present in chain
		if errors.As(err, &pathErr) {
			fmt.Printf("Failed on path: %s (Op: %s)\n", pathErr.Path, pathErr.Op)
			return
		}
		fmt.Println("Generic error:", err)
	}
}

4. Panic and Recover: When to Use Them?

Go provides panic and recover, but they are NOT meant to be used as general exceptions:

  • Use Errors for Expected Failures: Network timeouts, missing files, invalid input, database errors return an error.
  • Use Panic ONLY for Unrecoverable Programmer Bugs: A critical regex that failed to compile on startup (regexp.MustCompile), or unrecoverable invariant violations.

Recovering from Panics in HTTP Middleware

If a handler crashes, an HTTP server shouldn’t die. You can recover from panics in middleware:

func RecoveryMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("Panic recovered: %v", r)
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Across the Wiki