Error Handling Best Practices in Go
Error handling in Go is explicit and straightforward, but mastering it requires understanding the idioms and patterns that make Go code robust and maintainable. In this post, we’ll explore error handling best practices that will help you write production-quality Go code.
The Basics of Error Handling in Go
Go doesn’t have exceptions. Instead, functions return an error type as their last return value. This explicit approach forces you to think about error cases at every step.
func DoSomething() (string, error) {
result, err := someOperation()
if err != nil {
return "", err
}
return result, nil
}
Always Check Errors
The most important rule: never ignore errors. The blank identifier _ should rarely be used with errors.
// Bad - error is ignored
result, _ := DoSomething()
// Good - error is handled
result, err := DoSomething()
if err != nil {
return fmt.Errorf("failed to do something: %w", err)
}
Creating Custom Error Types
For complex applications, custom error types provide more context and enable better error handling.
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(name string) error {
if name == "" {
return &ValidationError{
Field: "name",
Message: "cannot be empty",
}
}
return nil
}
Checking Error Types
Use errors.As to check for specific error types:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Printf("Validation error on field: %s\n", validationErr.Field)
}
Error Wrapping with Context
Go 1.13 introduced error wrapping with %w, which preserves the original error while adding context.
func GetUser(id int) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("GetUser(%d): %w", id, err)
}
return user, nil
}
Unwrapping Errors
Use errors.Is to check if an error matches a specific value in the chain:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
Sentinel Errors
Define package-level error variables for common error conditions:
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrInvalidInput = errors.New("invalid input")
)
func GetResource(id string) (*Resource, error) {
if id == "" {
return nil, ErrInvalidInput
}
// ...
}
Best Practices
1. Add Context to Errors
Always add meaningful context when propagating errors up the call stack.
// Poor context
return err
// Better context
return fmt.Errorf("failed to process order %s: %w", orderID, err)
2. Handle Errors Once
Don’t log an error and then return it. Either handle it (log, recover) or return it, not both.
// Bad - error is handled twice
if err != nil {
log.Printf("error: %v", err)
return err
}
// Good - error is returned with context, logged at top level
if err != nil {
return fmt.Errorf("processing failed: %w", err)
}
3. Use defer for Cleanup
When you need to perform cleanup regardless of errors, use defer:
func ProcessFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file: %w", err)
}
defer f.Close()
// Process the file...
return nil
}
4. Return Early
Check errors and return early to keep the happy path left-aligned:
func Process(data []byte) error {
if len(data) == 0 {
return ErrEmptyData
}
result, err := parse(data)
if err != nil {
return fmt.Errorf("parsing data: %w", err)
}
if err := validate(result); err != nil {
return fmt.Errorf("validating result: %w", err)
}
return save(result)
}
5. Consider Error Behavior, Not Type
When designing APIs, think about what callers need to do with errors:
// Allows callers to check if they should retry
type TemporaryError interface {
Temporary() bool
}
func IsTemporary(err error) bool {
var temp TemporaryError
return errors.As(err, &temp) && temp.Temporary()
}
Testing Error Handling
Write tests that verify your error handling works correctly:
func TestGetUser_NotFound(t *testing.T) {
_, err := GetUser(999)
if !errors.Is(err, ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
}
func TestValidateUser_EmptyName(t *testing.T) {
err := ValidateUser("")
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatal("expected ValidationError")
}
if validationErr.Field != "name" {
t.Errorf("expected field 'name', got %s", validationErr.Field)
}
}
Wrapping Up
Effective error handling in Go comes down to a few key principles:
- Always check errors - never ignore them
- Add meaningful context when wrapping errors
- Use
errors.Isanderrors.Asfor error inspection - Define sentinel errors for common conditions
- Handle errors once, either by logging/recovering or returning
By following these practices, you’ll write Go code that’s easier to debug, maintain, and extend. For more on Go’s error handling philosophy, check out the official Go Blog post on errors.