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 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:gotype 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
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
gofunc 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
gofunc 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
| Practice | Why |
|---|---|
| Always check errors | Ignoring errors causes silent failures |
| Add context when wrapping | Makes debugging easier |
| Use %w for wrap | Preserves error chain for Is/As |
| Handle at appropriate level | Low level returns, top level handles |
| Use sentinel errors sparingly | Only for truly common conditions |
| Don't log and return | Choose 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.