Go's Unique Approach to Error Handling
Go takes a fundamentally different approach to error handling compared to languages with exceptions. Instead of throwing and catching exceptions, Go uses explicit error returns - errors are values that are returned from functions, making error handling visible and explicit in the code.
This design philosophy has several benefits:
- Explicit: Errors are visible in function signatures and call sites
- Simple: No hidden control flow or stack unwinding
- Composable: Errors can be wrapped, checked, and transformed
- Predictable: Error handling is part of the normal code flow
The Error Interface
At the heart of Go's error handling is the error interface, which is incredibly simple:
type error interface {
Error() string
}
Any type that implements this interface is an error. The Error() method returns a string description of the error.
package main
import (
"fmt"
"errors"
)
func main() {
err := errors.New("something went wrong")
fmt.Println(err.Error()) // "something went wrong"
fmt.Println(err) // "something went wrong" (fmt.Println calls Error() automatically)
}
Creating Errors
Go provides several ways to create errors, each suitable for different scenarios.
Using errors.New()
The simplest way to create an error is with errors.New():
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)
}
Output:
Error: division by zero
Using fmt.Errorf()
For formatted error messages, use fmt.Errorf():
package main
import (
"fmt"
)
func getUser(id int) (string, error) {
if id < 0 {
return "", fmt.Errorf("invalid user ID: %d (must be positive)", id)
}
// ... fetch user
return "user", nil
}
func main() {
_, err := getUser(-1)
if err != nil {
fmt.Printf("Error: %v\n", err)
}
}
Output:
Error: invalid user ID: -1 (must be positive)
Checking Errors
The idiomatic way to handle errors in Go is to check them explicitly:
result, err := someFunction()
if err != nil {
// Handle the error
return err // or handle it appropriately
}
// Continue with result
Common Error Checking Patterns
Pattern 1: Return Early
func processUser(id int) error {
user, err := getUser(id)
if err != nil {
return err // Return immediately
}
err = validateUser(user)
if err != nil {
return err
}
return saveUser(user)
}
Pattern 2: Log and Continue
func processUsers(ids []int) {
for _, id := range ids {
user, err := getUser(id)
if err != nil {
log.Printf("Failed to get user %d: %v", id, err)
continue // Skip this user, continue with next
}
processUser(user)
}
}
Pattern 3: Handle Specific Errors
func processFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file %s does not exist", filename)
}
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// ... process file
return nil
}
Error Wrapping (Go 1.13+)
Go 1.13 introduced error wrapping, allowing you to add context to errors while preserving the original error for inspection.
The %w Verb
Use fmt.Errorf() with the %w verb to wrap errors:
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(filename string) error {
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("failed to open config file: %w", err)
}
defer file.Close()
// ... read config
return nil
}
func main() {
err := readConfig("config.json")
if err != nil {
fmt.Printf("Error: %v\n", err)
// Output: Error: failed to open config file: open config.json: no such file or directory
}
}
The wrapped error preserves the original error, allowing you to inspect the error chain.
errors.Unwrap()
The errors.Unwrap() function retrieves the wrapped error:
package main
import (
"errors"
"fmt"
"os"
)
func main() {
err := fmt.Errorf("failed to open: %w", os.ErrNotExist)
unwrapped := errors.Unwrap(err)
fmt.Println(unwrapped == os.ErrNotExist) // true
}
errors.Is()
The errors.Is() function checks if any error in the error chain matches a target:
package main
import (
"errors"
"fmt"
"io"
"os"
)
func readFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
data := make([]byte, 100)
_, err = file.Read(data)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
return nil
}
func main() {
err := readFile("example.txt")
// Check if the error chain contains io.EOF
if errors.Is(err, io.EOF) {
fmt.Println("Reached end of file")
}
// Check if the error chain contains os.ErrNotExist
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File does not exist")
}
}
Key Points:
-
errors.Is()traverses the entire error chain - Works with wrapped errors created with
%w - Use for checking sentinel errors
errors.As()
The errors.As() function checks if any error in the chain is of a specific type and extracts it:
package main
import (
"errors"
"fmt"
"os"
"syscall"
)
func main() {
err := fmt.Errorf("operation failed: %w", &os.PathError{
Op: "open",
Path: "file.txt",
Err: syscall.ENOENT,
})
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("Path: %s\n", pathErr.Path)
fmt.Printf("Operation: %s\n", pathErr.Op)
fmt.Printf("Error: %v\n", pathErr.Err)
}
}
Key Points:
-
errors.As()extracts the error type from the chain - The second argument must be a pointer to the error type
- Use for checking custom error types
Custom Error Types
For structured error information, create custom error types:
package main
import (
"fmt"
)
// Custom error type with additional fields
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(name, email string) error {
if name == "" {
return &ValidationError{
Field: "name",
Message: "name is required",
}
}
if email == "" {
return &ValidationError{
Field: "email",
Message: "email is required",
}
}
return nil
}
func main() {
err := validateUser("", "")
if err != nil {
fmt.Println(err)
// Check if it's a ValidationError
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Field: %s\n", valErr.Field)
fmt.Printf("Message: %s\n", valErr.Message)
}
}
}
Output:
validation error on field 'name': name is required
Field: name
Message: name is required
When to Use Custom Error Types
Use custom error types when you need:
- Structured information: Multiple fields beyond a message
- Type checking: Different handling based on error type
- Additional methods: Behavior specific to the error type
Sentinel Errors
Sentinel errors are predefined error values that represent specific error conditions. They're typically declared at package level:
package main
import (
"errors"
"fmt"
)
// Sentinel errors
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidPassword = errors.New("invalid password")
ErrUnauthorized = errors.New("unauthorized")
)
func authenticate(username, password string) error {
user, err := findUser(username)
if err != nil {
return ErrUserNotFound
}
if !validatePassword(user, password) {
return ErrInvalidPassword
}
return nil
}
func main() {
err := authenticate("user", "wrong")
if errors.Is(err, ErrInvalidPassword) {
fmt.Println("Password is incorrect")
}
}
Common Sentinel Errors in Standard Library
The Go standard library provides many sentinel errors:
import (
"io"
"os"
)
// Check for end of file
if errors.Is(err, io.EOF) {
// Handle EOF
}
// Check if file doesn't exist
if errors.Is(err, os.ErrNotExist) {
// Handle file not found
}
// Check if permission denied
if errors.Is(err, os.ErrPermission) {
// Handle permission error
}
Best Practices for Sentinel Errors:
- Use for expected errors that callers should handle
- Make them exported (capitalized) so callers can check them
- Use descriptive names starting with
Err - Document when they're returned
Error Handling Patterns
Pattern 1: Error Propagation
Return errors up the call stack, adding context at each level:
func processOrder(orderID int) error {
order, err := getOrder(orderID)
if err != nil {
return fmt.Errorf("failed to get order %d: %w", orderID, err)
}
err = validateOrder(order)
if err != nil {
return fmt.Errorf("order %d validation failed: %w", orderID, err)
}
err = saveOrder(order)
if err != nil {
return fmt.Errorf("failed to save order %d: %w", orderID, err)
}
return nil
}
Pattern 2: Error Wrapping with Context
Add context at each level while preserving the original error:
func fetchUserData(userID int) (*UserData, error) {
user, err := getUser(userID)
if err != nil {
return nil, fmt.Errorf("fetchUserData: failed to get user: %w", err)
}
profile, err := getUserProfile(userID)
if err != nil {
return nil, fmt.Errorf("fetchUserData: failed to get profile: %w", err)
}
return &UserData{User: user, Profile: profile}, nil
}
Pattern 3: Structured Error Handling
Use custom error types for structured error information:
type APIError struct {
Code int
Message string
Details map[string]interface{}
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error [%d]: %s", e.Code, e.Message)
}
func makeAPIRequest(url string) error {
// ... make request
if statusCode == 404 {
return &APIError{
Code: 404,
Message: "Resource not found",
Details: map[string]interface{}{
"url": url,
},
}
}
return nil
}
Panic and Recover
While Go uses explicit error returns for normal error handling, panic and recover exist for truly exceptional situations.
When to Use Panic
Use panic for:
- Programming errors: Bugs that should be fixed, not handled
- Unrecoverable situations: When the program cannot continue
- Invariant violations: When assumptions are violated
Examples of appropriate panic usage:
// Programming error - should be fixed
func divide(a, b int) int {
if b == 0 {
panic("division by zero") // This is a bug, should be checked before calling
}
return a / b
}
// Invariant violation
func (s *Stack) Pop() int {
if s.isEmpty() {
panic("pop from empty stack") // Programming error
}
// ... pop logic
}
Recover
recover can only be used inside a defer function to catch panics:
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
result = a / b // This might panic if b == 0
return result, nil
}
func main() {
result, err := safeDivide(10, 0)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %d\n", result)
}
}
Important Notes:
-
recoveronly works in deferred functions - Use
recoversparingly - typically only at package boundaries - Don't use
panicfor normal error conditions - use error returns instead
Common Pitfalls
Pitfall 1: Ignoring Errors
❌ BAD:
file, _ := os.Open("file.txt") // Error ignored!
defer file.Close()
✅ GOOD:
file, err := os.Open("file.txt")
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
Pitfall 2: Overusing Panic
❌ BAD:
func getUser(id int) *User {
user, err := fetchUser(id)
if err != nil {
panic(err) // Don't panic for normal errors!
}
return user
}
✅ GOOD:
func getUser(id int) (*User, error) {
user, err := fetchUser(id)
if err != nil {
return nil, fmt.Errorf("failed to fetch user: %w", err)
}
return user, nil
}
Pitfall 3: Poor Error Messages
❌ BAD:
return errors.New("error")
✅ GOOD:
return fmt.Errorf("failed to connect to database at %s: %w", dbURL, err)
Pitfall 4: Not Adding Context
❌ BAD:
func processOrder(orderID int) error {
err := saveOrder(orderID)
return err // No context about what operation failed
}
✅ GOOD:
func processOrder(orderID int) error {
err := saveOrder(orderID)
if err != nil {
return fmt.Errorf("failed to save order %d: %w", orderID, err)
}
return nil
}
Best Practices
1. Always Check Errors
Never ignore errors. If you're not handling an error, at least log it:
result, err := someFunction()
if err != nil {
log.Printf("Warning: %v", err) // At minimum, log it
// Or handle it appropriately
}
2. Add Context When Wrapping
When wrapping errors, add meaningful context:
// Good: Adds context
return fmt.Errorf("failed to process user %d: %w", userID, err)
// Better: More specific context
return fmt.Errorf("userService: failed to update user %d: %w", userID, err)
3. Use Appropriate Error Types
Choose the right error creation method:
-
Simple errors:
errors.New() -
Formatted errors:
fmt.Errorf() -
Wrapped errors:
fmt.Errorf()with%w - Structured errors: Custom error types
4. Provide Clear Error Messages
Error messages should be:
- Descriptive: Explain what went wrong
- Actionable: Suggest what to do
- Contextual: Include relevant information (IDs, values, etc.)
// Good
return fmt.Errorf("failed to connect to database: connection timeout after 30s")
// Better
return fmt.Errorf("database connection failed: host=%s port=%d timeout=30s: %w",
host, port, err)
5. Use Sentinel Errors for Expected Conditions
For errors that callers should handle, use sentinel errors:
var ErrNotFound = errors.New("resource not found")
func findResource(id int) (*Resource, error) {
// ... lookup
if notFound {
return nil, ErrNotFound
}
return resource, nil
}
6. Document Error Returns
Document what errors your functions return:
// GetUser retrieves a user by ID.
// Returns ErrNotFound if the user doesn't exist.
func GetUser(id int) (*User, error) {
// ...
}
Real-World Example
Here's a complete example demonstrating error handling in a realistic scenario:
package main
import (
"errors"
"fmt"
"log"
"os"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidInput = errors.New("invalid input")
)
type User struct {
ID int
Name string
Email string
}
type UserService struct {
// ... dependencies
}
func (s *UserService) GetUser(id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("GetUser: %w: id=%d", ErrInvalidInput, id)
}
user, err := s.fetchUserFromDB(id)
if err != nil {
return nil, fmt.Errorf("GetUser: failed to fetch user %d: %w", id, err)
}
if user == nil {
return nil, fmt.Errorf("GetUser: %w: id=%d", ErrUserNotFound, id)
}
return user, nil
}
func (s *UserService) fetchUserFromDB(id int) (*User, error) {
// Simulate database error
return nil, fmt.Errorf("database connection failed")
}
func main() {
service := &UserService{}
user, err := service.GetUser(123)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
log.Printf("User not found: %v", err)
} else if errors.Is(err, ErrInvalidInput) {
log.Printf("Invalid input: %v", err)
} else {
log.Printf("Unexpected error: %v", err)
}
return
}
fmt.Printf("User: %+v\n", user)
}
Summary
Go's error handling is built on simple principles:
- Errors are values - returned explicitly from functions
- Check errors explicitly - no hidden control flow
- Add context - wrap errors with meaningful information
- Use appropriate types - simple errors, sentinel errors, or custom types
- Reserve panic for exceptional cases - use error returns for normal conditions
Key Takeaways:
- Always check errors - never ignore them
- Use
fmt.Errorf()with%wto wrap errors and add context - Use
errors.Is()to check for sentinel errors - Use
errors.As()to extract custom error types - Create custom error types for structured error information
- Use panic only for truly exceptional situations
- Provide clear, actionable error messages
Mastering error handling in Go is essential for writing robust, maintainable code. The explicit nature of Go's error handling makes it easier to reason about error flows and ensures that errors are handled appropriately throughout your application.
Top comments (0)