Have you ever found yourself battling with error handling in Go? You’re not alone. Coming from languages with try-catch blocks, Go’s approach to error handling might seem peculiar at first. But there’s beauty in this simplicity, and today we’ll see why Go’s error handling is a feature, not a bug.
Why Go’s Error Handling is Different
In Go, errors are values. That’s it. No exceptions throwing your program into chaos, no try-catch blocks nested three levels deep. Just values you can inspect, pass around, and handle explicitly.
func divide(x, y float64) (float64, error) {
if y == 0 {
return 0, errors.New("division by zero")
}
return x / y, nil
}
result, err := divide(10, 0)
if err != nil {
fmt.Println("Oops:", err)
return
}
fmt.Printf("Result: %.2f\n", result)
Creating Errors: The Three Musketeers
Let’s explore three common ways to create errors in Go:
- Simple errors with
errors.New()
:
err := errors.New("something went wrong")
- Formatted errors with
fmt.Errorf()
:
name := "config.json"
err := fmt.Errorf("failed to read file: %s", name)
- Custom error types for rich context:
type ValidationError struct {
Field string
Issue string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Issue)
}
// Using custom error
func validateAge(age int) error {
if age < 0 {
return &ValidationError{
Field: "age",
Issue: "cannot be negative",
}
}
return nil
}
Error Wrapping: The Gift of Context
Go 1.13 introduced error wrapping, and it’s a game-changer. Think of it as adding layers of context to your errors.
func readConfig() error {
err := loadFile()
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
return nil
}
func loadFile() error {
return fmt.Errorf("file not found")
}
// Using wrapped errors
err := readConfig()
if err != nil {
fmt.Println(err) // Output: failed to read config: file not found
// Unwrap to check original error
if errors.Is(err, os.ErrNotExist) {
fmt.Println("The file doesn't exist!")
}
}
Real-World Example: Building a User Service
Let’s put it all together with a practical example of a user service:
type UserService struct {
db *sql.DB
}
type User struct {
ID int
Email string
Name string
}
// Custom error types
type UserError struct {
Op string
Err error
}
func (e *UserError) Error() string {
return fmt.Sprintf("user operation %s failed: %v", e.Op, e.Err)
}
func (e *UserError) Unwrap() error {
return e.Err
}
func (s *UserService) CreateUser(email, name string) error {
if !strings.Contains(email, "@") {
return &UserError{
Op: "create",
Err: &ValidationError{Field: "email", Issue: "invalid format"},
}
}
exists, err := s.checkUserExists(email)
if err != nil {
return fmt.Errorf("failed to check user existence: %w", err)
}
if exists {
return &UserError{
Op: "create",
Err: fmt.Errorf("user with email %s already exists", email),
}
}
_, err = s.db.Exec("INSERT INTO users (email, name) VALUES (?, ?)", email, name)
if err != nil {
return fmt.Errorf("failed to insert user: %w", err)
}
return nil
}
func main() {
service := &UserService{db: setupDB()}
err := service.CreateUser("invalid-email", "John")
if err != nil {
var userErr *UserError
if errors.As(err, &userErr) {
fmt.Printf("User operation failed: %v\n", userErr)
var validationErr *ValidationError
if errors.As(userErr.Err, &validationErr) {
fmt.Printf("Validation failed: %s - %s\n",
validationErr.Field, validationErr.Issue)
}
}
return
}
}
Error Handling in Concurrent Code
When dealing with goroutines, error handling requires extra attention:
func processItems(items []string) error {
errs := make(chan error, len(items))
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(item string) {
defer wg.Done()
if err := processItem(item); err != nil {
errs <- fmt.Errorf("processing %s: %w", item, err)
}
}(item)
}
// Wait for all goroutines and close error channel
go func() {
wg.Wait()
close(errs)
}()
// Collect errors
var errList []error
for err := range errs {
errList = append(errList, err)
}
if len(errList) > 0 {
return fmt.Errorf("multiple processing errors: %v", errList)
}
return nil
}
Best Practices and Common Pitfalls
- Always handle your errors:
// DON'T
f.Close()
// DO
if err := f.Close(); err != nil {
log.Printf("failed to close file: %v", err)
}
- Add context when wrapping:
// DON'T
return fmt.Errorf("failed: %w", err)
// DO
return fmt.Errorf("failed to process user data: %w", err)
- Use sentinel errors for expected error conditions:
ErrUserNotFound := errors.New("user not found")
func GetUser(id string) (*User, error) {
user, ok := users[id]
if !ok {
return nil, ErrUserNotFound
}
return user, nil
}
Conclusion
Go’s error handling might seem verbose at first, but it forces you to think about what can go wrong and handle it appropriately. You can build robust and maintainable applications by treating errors as values and using the tools that Go provides (custom error types, error wrapping, and error inspection).
And, remember:
- Errors are just values
- Always add context when wrapping errors
- Use custom error types for rich error information
- Handle concurrent errors carefully
- Never ignore errors (unless you have a very good reason)
Additional Resources
Have you enjoyed this article? Clap 👏 and follow for more content!