Go Error Handling: errors Package Guide
Go's error handling philosophy is explicit and straightforward: errors are values that should be checked and handled at each call site. Unlike exception-based systems, Go forces you to deal with...
Key Insights
- Go’s
errorspackage provides error wrapping with%w, enabling error chains that preserve context while maintaining the ability to check for specific error types usingerrors.Is()anderrors.As() - Custom error types should implement
Unwrap(),Is(), orAs()methods when they need to participate in error chain inspection, allowing callers to extract meaningful information from wrapped errors - Wrap errors at package boundaries to add context, but avoid excessive wrapping within the same abstraction layer—each wrap should add meaningful information for debugging
Introduction to Go’s errors Package
Go’s error handling philosophy is explicit and straightforward: errors are values that should be checked and handled at each call site. Unlike exception-based systems, Go forces you to deal with errors immediately, making error paths visible in your code flow. The errors package, enhanced significantly in Go 1.13, provides the essential tools for creating, wrapping, and inspecting errors.
The core principle is simple: functions that can fail return an error as their last return value. Callers check this value and decide how to proceed. This explicitness might seem verbose at first, but it makes error handling predictable and debuggable.
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Printf("error: %v\n", err)
return
}
fmt.Printf("result: %f\n", result)
}
This pattern—returning an error and immediately checking it—is idiomatic Go. The errors.New() function creates a simple error with a static message, sufficient for many use cases.
Creating and Wrapping Errors
While errors.New() works for simple cases, real applications need to add context as errors propagate up the call stack. Go 1.13 introduced error wrapping with the %w verb in fmt.Errorf(), creating an error chain that preserves the original error while adding contextual information.
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(filename string) ([]byte, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
return data, nil
}
func loadUserConfig(userID string) ([]byte, error) {
filename := fmt.Sprintf("/etc/app/users/%s.conf", userID)
data, err := readConfig(filename)
if err != nil {
return nil, fmt.Errorf("load config for user %s: %w", userID, err)
}
return data, nil
}
func main() {
_, err := loadUserConfig("12345")
if err != nil {
fmt.Printf("error: %v\n", err)
// Output: error: load config for user 12345: read config: open /etc/app/users/12345.conf: no such file or directory
}
}
Each layer adds context while preserving the underlying error. The error message builds from the innermost error outward, creating a readable narrative of what went wrong. Crucially, the %w verb maintains the error chain, allowing inspection of the original error later.
Multi-level wrapping creates a linked list of errors, where each wrapped error points to the one beneath it. This chain can be traversed programmatically to check for specific error conditions or extract error details.
Unwrapping and Inspecting Errors
Error chains become powerful when you can inspect them. The errors package provides three functions for working with wrapped errors: Unwrap(), Is(), and As().
errors.Is() checks whether any error in the chain matches a target error, making it perfect for sentinel error checking:
package main
import (
"errors"
"fmt"
"os"
)
var ErrNotFound = errors.New("resource not found")
func fetchResource(id string) error {
// Simulate a not-found condition
if id == "missing" {
return fmt.Errorf("fetch resource %s: %w", id, ErrNotFound)
}
return nil
}
func main() {
err := fetchResource("missing")
// Check for specific error in the chain
if errors.Is(err, ErrNotFound) {
fmt.Println("Resource doesn't exist, creating it...")
}
// Also works with standard library errors
_, err = os.Open("/nonexistent/file")
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File doesn't exist")
}
}
errors.As() extracts specific error types from the chain, useful when you need to access custom error fields:
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
func validateUser(email string) error {
if email == "" {
verr := &ValidationError{Field: "email", Message: "required"}
return fmt.Errorf("user validation: %w", verr)
}
return nil
}
func main() {
err := validateUser("")
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Printf("Validation failed: field=%s, msg=%s\n",
validationErr.Field, validationErr.Message)
// Output: Validation failed: field=email, msg=required
}
}
errors.Unwrap() returns the next error in the chain, or nil if there isn’t one. While Is() and As() are generally preferred, Unwrap() is useful when you need manual control over chain traversal.
Custom Error Types
Custom error types let you attach structured data to errors, making them more informative than simple strings. A custom error must implement the error interface by providing an Error() string method.
package main
import (
"errors"
"fmt"
)
type DatabaseError struct {
Operation string
Table string
Err error
}
func (e *DatabaseError) Error() string {
return fmt.Sprintf("database %s failed on table %s: %v",
e.Operation, e.Table, e.Err)
}
func (e *DatabaseError) Unwrap() error {
return e.Err
}
func insertUser(name string) error {
// Simulate a constraint violation
baseErr := errors.New("unique constraint violation")
return &DatabaseError{
Operation: "insert",
Table: "users",
Err: baseErr,
}
}
func main() {
err := insertUser("john")
var dbErr *DatabaseError
if errors.As(err, &dbErr) {
fmt.Printf("DB operation: %s, Table: %s\n",
dbErr.Operation, dbErr.Table)
}
}
The Unwrap() method allows your custom error to participate in error chains. For more sophisticated scenarios, implement custom Is() or As() methods to control matching behavior:
type TemporaryError struct {
Err error
}
func (e *TemporaryError) Error() string {
return fmt.Sprintf("temporary error: %v", e.Err)
}
func (e *TemporaryError) Unwrap() error {
return e.Err
}
func (e *TemporaryError) Is(target error) bool {
_, ok := target.(*TemporaryError)
return ok
}
Error Handling Best Practices
Sentinel errors—package-level error variables—provide a stable API for error checking. Define them as exported variables when callers need to make decisions based on specific errors:
package storage
import "errors"
var (
ErrNotFound = errors.New("item not found")
ErrAlreadyExists = errors.New("item already exists")
ErrUnauthorized = errors.New("unauthorized access")
)
func Get(key string) (interface{}, error) {
// Implementation
return nil, ErrNotFound
}
Wrap errors at package boundaries to add context, but don’t wrap excessively within the same layer:
// Good: wrapping at package boundary
func (s *Service) ProcessOrder(orderID string) error {
order, err := s.repo.GetOrder(orderID)
if err != nil {
return fmt.Errorf("process order %s: %w", orderID, err)
}
// ...
}
// Bad: over-wrapping in the same abstraction
func (s *Service) helper1() error {
err := s.helper2()
if err != nil {
return fmt.Errorf("helper1: %w", err) // Unnecessary
}
return nil
}
Avoid losing error context by always using %w when you want to preserve the error chain. Use %v only when you explicitly want to break the chain and create a new error root.
Practical Patterns and Real-World Usage
Real applications often need to handle multiple errors, especially during cleanup operations:
package main
import (
"errors"
"fmt"
"io"
)
func cleanup(resources []io.Closer) error {
var errs []error
for _, r := range resources {
if err := r.Close(); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("cleanup failed with %d errors: %v",
len(errs), errs)
}
return nil
}
Context cancellation integrates naturally with error handling:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func processWithTimeout(ctx context.Context, data string) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
// Simulate work
time.Sleep(3 * time.Second)
done <- nil
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("process data: %w", ctx.Err())
}
}
func main() {
err := processWithTimeout(context.Background(), "test")
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("Operation timed out")
}
}
File operations benefit from proper error wrapping to maintain the error chain while adding application-specific context:
package main
import (
"errors"
"fmt"
"os"
)
func saveReport(filename string, data []byte) error {
f, err := os.Create(filename)
if err != nil {
return fmt.Errorf("create report file: %w", err)
}
defer f.Close()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write report data: %w", err)
}
if err := f.Sync(); err != nil {
return fmt.Errorf("sync report to disk: %w", err)
}
return nil
}
func main() {
err := saveReport("/readonly/report.txt", []byte("data"))
if err != nil {
if errors.Is(err, os.ErrPermission) {
fmt.Println("Permission denied - check file permissions")
} else {
fmt.Printf("Failed to save report: %v\n", err)
}
}
}
The errors package transforms Go’s simple error interface into a powerful system for creating informative, inspectable error chains. By wrapping errors with context, defining sentinel errors for your APIs, and using Is() and As() for inspection, you create code that’s both robust and debuggable. The key is finding the balance between adding useful context and avoiding noise—every error wrap should answer the question “what was the code trying to do when this failed?”