Error Handling
Learn Go's explicit error handling approach. Understand the error interface, creating custom errors, wrapping errors for context, and designing robust error handling strategies.
Most operations can fail. Files don't exist. Network connections timeout. Users provide invalid input. Disk space runs out. Code must handle these failures, and different languages take different approaches. Exceptions hide failure paths in the control flow. Go makes errors explicit—they're values returned from functions.
The Error Interface
Errors are values implementing a simple interface:
type error interface {
Error() string
}
Any type with an Error() method is an error. Functions that can fail return an error as their last return value:
func ReadFile(filename string) ([]byte, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
return data, nil
}
The pattern is consistent: operations that can fail return (result, error). If the operation succeeds, the error is nil. If it fails, the error describes what went wrong.
Callers check the error immediately:
data, err := ReadFile("config.json")
if err != nil {
log.Fatal(err)
}
// Use data
This explicitness makes error paths visible. You see exactly where errors occur and how they're handled.
Creating Errors
The errors package creates simple errors:
import "errors"
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
For formatted errors, use fmt.Errorf:
func ValidateAge(age int) error {
if age < 0 {
return fmt.Errorf("invalid age: %d", age)
}
if age > 150 {
return fmt.Errorf("age %d exceeds maximum", age)
}
return nil
}
These functions allocate new errors on each call. For errors that occur frequently, define them as package variables:
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
func FindUser(id int) (*User, error) {
if id < 0 {
return nil, ErrInvalidInput
}
user := lookupUser(id)
if user == nil {
return nil, ErrNotFound
}
return user, nil
}
Callers can compare against these sentinel errors:
user, err := FindUser(id)
if err == ErrNotFound {
// Handle not found specifically
} else if err != nil {
// Handle other errors
}
Custom Error Types
Create custom error types for errors that need additional information:
type ValidationError struct {
Field string
Value any
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: %s=%v: %s",
e.Field, e.Value, e.Msg)
}
func ValidateUser(user *User) error {
if user.Email == "" {
return &ValidationError{
Field: "email",
Value: user.Email,
Msg: "required field",
}
}
if !strings.Contains(user.Email, "@") {
return &ValidationError{
Field: "email",
Value: user.Email,
Msg: "invalid format",
}
}
return nil
}
Callers can type assert to access the fields:
err := ValidateUser(user)
if err != nil {
if ve, ok := err.(*ValidationError); ok {
fmt.Printf("Field %s failed validation: %s\n",
ve.Field, ve.Msg)
} else {
fmt.Println("Error:", err)
}
}
Custom error types provide structured information that callers can inspect programmatically.
Error Wrapping
When a function calls another function that returns an error, you often want to add context. Error wrapping attaches additional information while preserving the original error:
func LoadConfig(filename string) (*Config, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &config, nil
}
The %w verb wraps the error. The resulting error contains both the context ("loading config") and the original error.
Callers can unwrap to check the original error:
config, err := LoadConfig("app.json")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// File doesn't exist
fmt.Println("Config file not found")
} else {
fmt.Println("Error:", err)
}
}
The errors.Is function unwraps the error chain and checks if any error matches os.ErrNotExist.
Checking Wrapped Errors
The errors package provides two functions for working with wrapped errors:
errors.Is checks if an error matches a target:
err := LoadConfig("missing.json")
if errors.Is(err, os.ErrNotExist) {
// The underlying error is os.ErrNotExist
}
This works through multiple layers of wrapping:
err1 := os.ErrNotExist
err2 := fmt.Errorf("reading file: %w", err1)
err3 := fmt.Errorf("loading config: %w", err2)
errors.Is(err3, os.ErrNotExist) // true
errors.As checks if an error is or wraps a specific type:
err := ValidateUser(user)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("Field:", ve.Field)
fmt.Println("Message:", ve.Msg)
}
errors.As unwraps the error chain until it finds a type matching *ValidationError, then assigns it to ve.
Both functions handle wrapped errors automatically. Use errors.Is for sentinel errors, errors.As for custom error types.
Error Handling Patterns
Early returns keep code flat:
func ProcessRequest(r *http.Request) (*Result, error) {
user, err := authenticate(r)
if err != nil {
return nil, err
}
data, err := parseBody(r)
if err != nil {
return nil, err
}
result, err := process(user, data)
if err != nil {
return nil, err
}
return result, nil
}
Each error check returns immediately. The happy path stays unindented.
Adding context at each layer:
func SaveUser(user *User) error {
if err := validate(user); err != nil {
return fmt.Errorf("validating user: %w", err)
}
if err := db.Insert(user); err != nil {
return fmt.Errorf("inserting user %s: %w", user.ID, err)
}
return nil
}
Each layer adds context. The final error might be:
inserting user 123: database connection failed: connection refused
The error message traces the call path.
Handling different error types:
data, err := fetchData(url)
if err != nil {
var netErr *net.OpError
if errors.As(err, &netErr) {
// Network error: retry
return retry(url)
}
if errors.Is(err, ErrRateLimited) {
// Rate limited: back off
return backoff(url)
}
// Unknown error: fail
return nil, err
}
Different errors require different handling. Type assertions and errors.Is distinguish them.
Don't Ignore Errors
The blank identifier discards values, including errors:
data, _ := os.ReadFile("config.json") // Bad: ignores error
This compiles, but ignoring errors leads to bugs. The file might not exist, permissions might be denied, disk might be full. Without checking, the program continues with zero-value data.
If you genuinely don't care about an error (rare), comment why:
// Closing response body, nothing we can do if it fails
_ = resp.Body.Close()
Most of the time, handle the error:
if err := resp.Body.Close(); err != nil {
log.Printf("closing response body: %v", err)
}
Error Variables vs Error Values
Package-level error variables are comparable with ==:
var ErrNotFound = errors.New("not found")
if err == ErrNotFound {
// Handle not found
}
But errors created with fmt.Errorf are not:
err1 := fmt.Errorf("not found")
err2 := fmt.Errorf("not found")
err1 == err2 // false: different allocations
Each call to fmt.Errorf allocates a new error. Use sentinel errors for comparison.
For wrapped errors, use errors.Is:
err := fmt.Errorf("loading user: %w", ErrNotFound)
err == ErrNotFound // false: err is wrapped
errors.Is(err, ErrNotFound) // true: unwraps and compares
Multiple Errors
Some operations produce multiple errors. The errors package provides Join to combine them:
func ValidateUser(user *User) error {
var errs []error
if user.Name == "" {
errs = append(errs, errors.New("name required"))
}
if user.Email == "" {
errs = append(errs, errors.New("email required"))
}
if user.Age < 0 {
errs = append(errs, errors.New("age must be positive"))
}
return errors.Join(errs...)
}
The joined error's Error() method returns all errors concatenated. Callers can unwrap and check individual errors:
err := ValidateUser(user)
if err != nil {
// Check for specific errors
if errors.Is(err, someSpecificError) {
// Handle
}
}
Panic and Recover
Go has panic for unrecoverable errors—programmer mistakes, impossible states, invariant violations:
func divide(a, b int) int {
if b == 0 {
panic("division by zero")
}
return a / b
}
Panics unwind the stack, running deferred functions, then crash the program. Use them for bugs, not expected errors:
// Bad: panic for expected errors
func ReadConfig() *Config {
data, err := os.ReadFile("config.json")
if err != nil {
panic(err) // Don't panic for missing files
}
// ...
}
// Good: return error
func ReadConfig() (*Config, error) {
data, err := os.ReadFile("config.json")
if err != nil {
return nil, err
}
// ...
}
The recover function catches panics:
func SafeProcess(data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
process(data) // Might panic
return nil
}
Recover is rare. Use it at boundaries—HTTP handlers, goroutine entry points—to prevent panics from crashing the entire program. Most code should return errors, not panic.
Error Messages
Write error messages for humans. Include enough context to understand what failed:
Bad:
errors.New("failed")
errors.New("error")
fmt.Errorf("invalid")
Good:
fmt.Errorf("reading config file: %w", err)
fmt.Errorf("invalid email format: %s", email)
fmt.Errorf("user %d not found", userID)
Error messages accumulate context as they propagate up the stack. The final message should tell you where it failed and why:
processing order 12345: updating inventory: product ABC123 not found
Lowercase error messages unless they start with proper nouns or acronyms. Don't end with punctuation:
errors.New("file not found") // Good
errors.New("File not found.") // Bad
errors.New("loading HTTP response") // Good (HTTP is acronym)
Error messages chain together. Ending with punctuation creates awkward combinations:
loading config: parsing JSON: unexpected token.
Errors vs Exceptions
Go's approach differs from exceptions. Exceptions hide control flow—you throw an exception somewhere, and it bubbles up until caught. The call path isn't visible in the code.
Go makes errors explicit. Every function that can fail returns an error. You see the failure points:
user, err := fetchUser(id)
if err != nil {
return nil, err
}
order, err := createOrder(user)
if err != nil {
return nil, err
}
This verbosity is intentional. Error handling is control flow, and control flow should be obvious.
Exceptions encourage broad catch blocks that handle disparate errors identically:
try {
fetchUser(id);
createOrder();
sendEmail();
} catch (Exception e) {
log(e);
}
Go forces you to handle errors at each step. Different errors receive different treatment.
The tradeoff is more code. Error checking appears throughout Go programs. This is considered acceptable because it makes error paths explicit and forces you to think about failure cases.
When to Return Errors
Return errors for expected failure conditions:
- File doesn't exist
- Network timeout
- Invalid input
- Resource not found
- Permission denied
Use panic for programmer errors:
- Array index out of bounds
- Nil pointer dereference
- Type assertion failure
- Impossible state
The distinction is: can the calling code reasonably handle this? If yes, return an error. If no, panic.
What's Next
Error handling in Go is explicit and uses values. Functions return errors, callers check them, and wrapped errors preserve context. This approach makes error paths visible and forces thoughtful handling.
The next article explores package organization—how to structure Go projects, manage imports, control visibility, and use modules. Understanding packages is essential for organizing code as programs grow beyond single files.
After packages, we'll cover testing. Go's testing package makes tests feel like regular code. Table-driven tests scale to hundreds of cases, benchmarks measure performance, and coverage reports show what's tested.
Ready to explore package organization and project structure?
