Go Error Handling: From Basics to Production Grade Patterns

The Mystery Failure

Your service returns "something went wrong" to users. You check the logs. They say "error occurred." Which error? Where? Why? You spend hours adding log statements, redeploying, and guessing.
This happens when error handling is an afterthought. Go's explicit error handling looks verbose at first, but it's designed to prevent exactly this mystery. When done right, errors tell you exactly what went wrong, where, and why.

Why Go Does Errors Differently

Many languages use exceptions. When something fails, they throw an exception that bubbles up until something catches it. The problem? You can't see which functions might fail by looking at their signature.
Go blog diagram 1

Go blog diagram 1

Go makes errors explicit. Functions return errors as values. You see immediately which operations can fail. You decide exactly how to handle each error.

The Package Delivery Analogy

Think of it like this: When you send a package, you don't just throw it toward the destination and hope. You get tracking. At each step, the carrier reports status. If something goes wrong, you know exactly where and why. Go errors work the same way. Each function reports what happened. You decide how to handle it.

Error Basics: The error Interface

In Go, an error is simply any type that implements the error interface:
go
type error interface { Error() string }
That's it. One method. Return a string describing what went wrong.
go
// Filename: basic_errors.go package main import ( "errors" "fmt" ) func divide(a, b float64) (float64, error) { if b == 0 { // errors.New creates a simple error return 0, errors.New("division by zero") } return a / b, nil } func main() { result, err := divide(10, 0) if err != nil { fmt.Println("Error:", err) return } fmt.Println("Result:", result) }
Expected Output:
Error: division by zero

Creating Custom Errors

For more context, create custom error types.
go
// Filename: custom_errors.go package main import "fmt" // ValidationError provides details about what failed validation type ValidationError struct { Field string Message string } // Error implements the error interface func (e *ValidationError) Error() string { return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message) } // ParseError includes position information type ParseError struct { Line int Column int Reason string } func (e *ParseError) Error() string { return fmt.Sprintf("parse error at %d:%d: %s", e.Line, e.Column, e.Reason) } func validateEmail(email string) error { if len(email) == 0 { return &ValidationError{ Field: "email", Message: "cannot be empty", } } return nil } func main() { err := validateEmail("") if err != nil { fmt.Println(err) // Type assertion to get specific details if ve, ok := err.(*ValidationError); ok { fmt.Printf("Failed field: %s\n", ve.Field) } } }
Expected Output:
validation error on email: cannot be empty Failed field: email

Wrapping Errors: Adding Context

When an error travels up through multiple functions, add context at each level.
go
// Filename: error_wrapping.go package main import ( "errors" "fmt" "os" ) func readConfig(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { // Wrap the original error with context return nil, fmt.Errorf("reading config file: %w", err) } return data, nil } func loadSettings() error { _, err := readConfig("/etc/app/config.yaml") if err != nil { // Add another layer of context return fmt.Errorf("loading settings: %w", err) } return nil } func initApp() error { err := loadSettings() if err != nil { return fmt.Errorf("initializing app: %w", err) } return nil } func main() { err := initApp() if err != nil { fmt.Println("Error:", err) // Unwrap to check original cause if errors.Is(err, os.ErrNotExist) { fmt.Println("The config file doesn't exist") } } }
Expected Output:
Error: initializing app: loading settings: reading config file: open /etc/app/config.yaml: no such file or directory The config file doesn't exist
Go blog diagram 2

Go blog diagram 2

errors.Is: Checking Error Types

errors.Is checks if an error (or any error in its chain) matches a target.
go
// Filename: errors_is.go package main import ( "errors" "fmt" "os" ) var ErrNotFound = errors.New("not found") func findUser(id int) error { // User doesn't exist return fmt.Errorf("user %d: %w", id, ErrNotFound) } func main() { err := findUser(123) // errors.Is checks the entire error chain if errors.Is(err, ErrNotFound) { fmt.Println("User not found - maybe create one?") } // Also works with standard library errors err2 := fmt.Errorf("operation failed: %w", os.ErrPermission) if errors.Is(err2, os.ErrPermission) { fmt.Println("Permission denied") } }
Expected Output:
User not found - maybe create one? Permission denied

errors.As: Extracting Error Details

errors.As extracts a specific error type from the chain.
go
// Filename: errors_as.go package main import ( "errors" "fmt" ) type NetworkError struct { Operation string Err error } func (e *NetworkError) Error() string { return fmt.Sprintf("network error during %s: %v", e.Operation, e.Err) } func (e *NetworkError) Unwrap() error { return e.Err } func fetchData() error { // Simulate network failure return &NetworkError{ Operation: "GET /api/users", Err: errors.New("connection refused"), } } func main() { err := fetchData() // Extract the NetworkError to access its fields var netErr *NetworkError if errors.As(err, &netErr) { fmt.Printf("Failed operation: %s\n", netErr.Operation) fmt.Printf("Underlying error: %v\n", netErr.Err) } }
Expected Output:
Failed operation: GET /api/users Underlying error: connection refused

Sentinel Errors: Known Error Values

Sentinel errors are predefined error values for common conditions.
go
// Filename: sentinel_errors.go package main import ( "errors" "fmt" ) // Sentinel errors are package-level variables var ( ErrNotFound = errors.New("not found") ErrUnauthorized = errors.New("unauthorized") ErrInvalidInput = errors.New("invalid input") ) type UserRepository struct { users map[int]string } func (r *UserRepository) Get(id int) (string, error) { user, exists := r.users[id] if !exists { return "", ErrNotFound } return user, nil } func main() { repo := &UserRepository{ users: map[int]string{1: "Alice", 2: "Bob"}, } // Check for specific error _, err := repo.Get(999) switch { case errors.Is(err, ErrNotFound): fmt.Println("User not found - return 404") case errors.Is(err, ErrUnauthorized): fmt.Println("Not authorized - return 401") case err != nil: fmt.Println("Unknown error:", err) } }
Expected Output:
User not found - return 404

Error Handling Patterns

Pattern 1: Early Return

go
func processData(data []byte) error { if len(data) == 0 { return errors.New("empty data") } parsed, err := parse(data) if err != nil { return fmt.Errorf("parsing: %w", err) } if err := validate(parsed); err != nil { return fmt.Errorf("validation: %w", err) } if err := save(parsed); err != nil { return fmt.Errorf("saving: %w", err) } return nil }

Pattern 2: Error Handler Function

go
func handleErrors(err error) { if err == nil { return } var netErr *NetworkError var valErr *ValidationError switch { case errors.As(err, &netErr): logNetworkError(netErr) retryOrFallback() case errors.As(err, &valErr): sendUserFeedback(valErr) default: logUnknownError(err) alertOncall() } }

Pattern 3: Collecting Multiple Errors

go
// Filename: multi_error.go package main import ( "errors" "fmt" "strings" ) type MultiError struct { Errors []error } func (m *MultiError) Error() string { var msgs []string for _, err := range m.Errors { msgs = append(msgs, err.Error()) } return strings.Join(msgs, "; ") } func (m *MultiError) Add(err error) { if err != nil { m.Errors = append(m.Errors, err) } } func (m *MultiError) HasErrors() bool { return len(m.Errors) > 0 } func validateUser(name, email string, age int) error { errs := &MultiError{} if len(name) < 2 { errs.Add(errors.New("name too short")) } if !strings.Contains(email, "@") { errs.Add(errors.New("invalid email")) } if age < 0 { errs.Add(errors.New("age cannot be negative")) } if errs.HasErrors() { return errs } return nil } func main() { err := validateUser("A", "invalid", -5) if err != nil { fmt.Println("Validation errors:", err) } }
Expected Output:
Validation errors: name too short; invalid email; age cannot be negative

Best Practices

Go blog diagram 3

Go blog diagram 3

PracticeWhy
Always check errorsIgnoring errors causes silent failures
Add context when wrappingMakes debugging easier
Use %w for wrapPreserves error chain for Is/As
Handle at appropriate levelLow level returns, top level handles
Use sentinel errors sparinglyOnly for truly common conditions
Don't log and returnChoose one to avoid duplicate logs

Common Mistakes

Mistake 1: Ignoring errors
go
// WRONG: Error ignored result, _ := doSomething() // RIGHT: Always handle result, err := doSomething() if err != nil { return err }
Mistake 2: Losing the error chain
go
// WRONG: Creates new error, loses original return fmt.Errorf("failed: %v", err) // %v, not %w // RIGHT: Wraps and preserves original return fmt.Errorf("failed: %w", err) // %w wraps
Mistake 3: Checking error strings
go
// WRONG: Fragile, breaks on message changes if err.Error() == "not found" { // ... } // RIGHT: Use sentinel or type checking if errors.Is(err, ErrNotFound) { // ... }
Mistake 4: Panic for expected errors
go
// WRONG: Panic for business logic if user == nil { panic("user not found") } // RIGHT: Return error if user == nil { return nil, ErrNotFound }

Error Hierarchy Example

go
// Filename: error_hierarchy.go package main import ( "errors" "fmt" ) // Base errors var ( ErrDatabase = errors.New("database error") ErrValidation = errors.New("validation error") ErrNotFound = errors.New("not found") ) // Specific errors wrap base errors func NewNotFoundError(entity string, id int) error { return fmt.Errorf("%s with id %d: %w", entity, id, ErrNotFound) } func NewDatabaseError(operation string, cause error) error { return fmt.Errorf("database %s: %w: %v", operation, ErrDatabase, cause) } func main() { err := NewNotFoundError("user", 123) // Can check both specific and general fmt.Println("Is ErrNotFound:", errors.Is(err, ErrNotFound)) dbErr := NewDatabaseError("insert", errors.New("connection timeout")) fmt.Println("Is ErrDatabase:", errors.Is(dbErr, ErrDatabase)) }
Expected Output:
Is ErrNotFound: true Is ErrDatabase: true

What You Learned

You now understand that:
  • Errors are values: Simple interface, powerful patterns
  • Wrap with %w: Preserves the error chain
  • errors.Is checks types: Works through wrapping
  • errors.As extracts details: Get specific error information
  • Sentinel errors are constants: Predefined known conditions
  • Context matters: Add context as errors bubble up

Your Next Steps

  • Audit: Review your codebase for ignored errors
  • Read Next: Learn about panic and recover for truly exceptional cases
  • Implement: Create a custom error type for your domain
Error handling in Go is explicit, which makes it powerful. When you embrace this explicitness, you build systems that fail gracefully and debug easily. No more mystery failures. Every error tells a story.
All Blogs
Tags:golangerror-handlingbest-practicesdebugging