Error Handling Best Practices in Go
Introduction
Error handling is one of the most distinctive aspects of Go programming. Unlike languages that use exceptions such as Java, Go treats errors as values that must be explicitly handled. At first, this approach may feel repetitive, but in practice it leads to code that is easier to reason about and debug.
In this post, I'll go beyond the basics and share Go's error handling philosophy, common patterns, and the traps I've seen developers (myself included) fall into. We'll look at idiomatic practices, custom error types, wrapping, debugging strategies, and how to apply them in real applications (web, database, concurrency). By the end, you should be able to design error flows that are both reliable for users and maintainable for developers.
Go's Error Handling Philosophy
Go's approach to error handling is based on several key principles:
- Errors are values: they behave like regular return values.
- Explicit handling: you must check and act on them.
- No hidden control flow: no try/catch blocks changing execution unexpectedly.
- Fail fast: surface problems early instead of letting them propagate silently.
This explicit style forces developers to think about error cases right where they occur.
The error interface
In Go, an error is any type that implements the error
interface:
type error interface {
Error() string
}
A basic error can be created with errors.New()
:
package main
import (
"errors"
"fmt"
)
func divide(n, d float64) (float64, error) {
if d == 0 {
return 0, errors.New("division by zero")
}
return n / d, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Result: %.2f\n", result)
}
In this example, divide
returns an error if the denominator is zero.
In the main
function, we follow the key principles:
- We check the error immediately after the function call.
- We handle it explicitly by printing a message and returning early.
- If there's no error, we proceed to use the result.
Basic Error Handling Patterns
The standard pattern
The most common way to handle errors is:
result, err := aFunction()
if err != nil {
// Handle the error and return early
return err
}
// Continue with the result
Antipattern: ignoring the error:
// ❌ Don't do this
result, _ := aFunction()
This often hides real problems until production.
Multiple return values
Go functions often return both a value and an error:
package main
import (
"fmt"
"strconv"
)
func parseAge(s string) (int, error) {
age, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid age format: %w", err)
}
if age < 0 || age > 150 {
return 0, fmt.Errorf("age %d is out of valid range (0-150)", age)
}
return age, nil
}
func main() {
validCases := []string{"25", "0", "150"}
invalidCases := []string{"abc", "-5", "200", "25.5"}
fmt.Println("Valid cases:")
for _, ageStr := range validCases {
age, err := parseAge(ageStr)
if err != nil {
fmt.Printf("❌ %s: %v\n", ageStr, err)
} else {
fmt.Printf("✅ %s: %d\n", ageStr, age)
}
}
fmt.Println("\nInvalid cases:")
for _, ageStr := range invalidCases {
age, err := parseAge(ageStr)
if err != nil {
fmt.Printf("❌ %s: %v\n", ageStr, err)
} else {
fmt.Printf("✅ %s: %d\n", ageStr, age)
}
}
}
In this example, parseAge
returns both the parsed age and an error if the input is invalid.
The main
function demonstrates handling both valid and invalid inputs, printing appropriate messages for each case.
Early returns
Avoid deeply nested if
blocks by returning early:
func processUser(userID string) error {
user, err := getUserByID(userID)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if !user.IsActive {
return fmt.Errorf("user %s is not active", userID)
}
err = validateUser(user)
if err != nil {
return fmt.Errorf("user validation failed: %w", err)
}
err = saveUser(user)
if err != nil {
return fmt.Errorf("failed to save user: %w", err)
}
return nil
}
In this example, each error is handled immediately, keeping the code flat and readable.
Custom Error Types
Custom errors add context and can be matched more precisely. They're useful when your domain has distinct error classes (validation, network, database).
package main
import (
"fmt"
"net/http"
"strings"
)
// ValidationError represents a validation error
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)
}
// HttpError represents an http-related error
type HttpError struct {
StatusCode int
URL string
Err error
}
func (e *HttpError) Error() string {
return fmt.Sprintf("http error (%d) for URL %s: %v", e.StatusCode, e.URL, e.Err)
}
func (e *HttpError) Unwrap() error {
return e.Err
}
// DatabaseError represents a database error
type DatabaseError struct {
Operation string
Table string
Err error
}
func (e *DatabaseError) Error() string {
return fmt.Sprintf("database error during %s on table %s: %v", e.Operation, e.Table, e.Err)
}
func (e *DatabaseError) Unwrap() error {
return e.Err
}
// Example functions that return custom errors
func validateEmail(email string) error {
if email == "" {
return &ValidationError{
Field: "email",
Message: "email is required",
}
}
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Message: "email must contain @ symbol",
}
}
return nil
}
func fetchUserData(url string) error {
// Simulate http request
if url == "" {
return &HttpError{
StatusCode: http.StatusBadRequest,
URL: url,
Err: fmt.Errorf("empty URL provided"),
}
}
// Simulate http error
return &HttpError{
StatusCode: http.StatusNotFound,
URL: url,
Err: fmt.Errorf("user not found"),
}
}
func saveUserToDB(userID string) error {
if userID == "" {
return &DatabaseError{
Operation: "INSERT",
Table: "users",
Err: fmt.Errorf("user ID cannot be empty"),
}
}
return nil
}
func main() {
// Test validation error
err := validateEmail("invalid-email")
if err != nil {
fmt.Printf("Validation error: %v\n", err)
}
// Test network error
err = fetchUserData("")
if err != nil {
fmt.Printf("Network error: %v\n", err)
}
// Test database error
err = saveUserToDB("")
if err != nil {
fmt.Printf("Database error: %v\n", err)
}
}
In this example, we define three custom error types: ValidationError
, HttpError
, and DatabaseError
.
Each type includes relevant fields and implements the Error()
method to provide a descriptive message.
The main
function calls functions that return these custom errors and prints the error messages by using the %v
verb in fmt.Printf
, which invokes the Error()
method of each error type.
Tip
Don't create custom error types for every case. Use them only where downstream code really needs to distinguish one failure from another.
Error Wrapping and Unwrapping
Go 1.13 introduced wrapping, allowing you to add context without losing the original cause.
Basic error wrapping
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(filename string) error {
_, err := os.Open(filename)
if err != nil {
return fmt.Errorf("failed to read config file %s: %w", filename, err)
}
return nil
}
func initialize() error {
err := readConfig("blog.marcnuri.com.config.yaml")
if err != nil {
return fmt.Errorf("initialization failed: %w", err)
}
return nil
}
func main() {
err := initialize()
if err != nil {
fmt.Printf("Application error: %v\n", err)
// Check for specific error types
if errors.Is(err, os.ErrNotExist) {
fmt.Println("The config file doesn't exist")
}
// Unwrap to get the original error
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("Path error occurred: %s\n", pathErr.Path)
}
}
}
In this example, readConfig
wraps the error returned by os.Open
with additional context.
The initialize
function further wraps this error.
In main
, we print the full error chain and use errors.Is
and errors.As
to check for specific error types.
Let's see how to use these functions effectively.
The errors.Is and errors.As functions
These functions help you detect or extract wrapped errors:
package main
import (
"errors"
"fmt"
"os"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidInput = errors.New("invalid input")
)
func findUser(id string) error {
if id == "" {
return fmt.Errorf("empty user ID: %w", ErrInvalidInput)
}
if id == "404" {
return fmt.Errorf("user lookup failed: %w", ErrUserNotFound)
}
return nil
}
func main() {
testCases := []string{"", "404", "123"}
for _, userID := range testCases {
err := findUser(userID)
if err != nil {
fmt.Printf("User ID %s: %v\n", userID, err)
// Check for specific errors using errors.Is
if errors.Is(err, ErrUserNotFound) {
fmt.Println(" → This is a user not found error")
}
if errors.Is(err, ErrInvalidInput) {
fmt.Println(" → This is an invalid input error")
}
// Check for system errors
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf(" → Path error: %s\n", pathErr.Path)
}
} else {
fmt.Printf("User ID %s: Success\n", userID)
}
fmt.Println()
}
}
In this example, we define two sentinel errors: ErrUserNotFound
and ErrInvalidInput
.
These errors are wrapped with context in the findUser
function depending on the input.
In the main
function, we test various user IDs and print the resulting errors.
We use errors.Is
to check if the error matches our sentinel errors and errors.As
to check for system errors like os.PathError
.
Tip
Avoid wrapping at every single layer. Wrap only when you're adding useful context; otherwise you risk noisy, unreadable error chains.
Error Handling in Different Contexts
Now that we've covered the fundamentals, let's see how to apply these patterns in real-world scenarios.
Web applications
When building APIs, a common pattern is to convert Go errors into structured JSON responses that clients can understand.
Here's a concise example that shows how to define a custom error type and a helper function to map errors into HTTP status codes:
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
)
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *APIError) Error() string { return e.Message }
func writeError(w http.ResponseWriter, err error) {
var apiErr *APIError
if !errors.As(err, &apiErr) {
apiErr = &APIError{"INTERNAL_ERROR", "internal server error"}
}
status := map[string]int{
"USER_NOT_FOUND": http.StatusNotFound,
"INVALID_INPUT": http.StatusBadRequest,
}[apiErr.Code]
if status == 0 {
status = http.StatusInternalServerError
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(apiErr)
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
writeError(w, &APIError{"INVALID_INPUT", "user ID is required"})
return
}
if id == "404" {
writeError(w, &APIError{"USER_NOT_FOUND", fmt.Sprintf("no user with id %s", id)})
return
}
json.NewEncoder(w).Encode(map[string]string{"id": id, "name": "John Doe"})
}
func main() {
http.HandleFunc("/user", getUserHandler)
http.ListenAndServe(":8080", nil)
}
In this example, we use a single APIError
type to represent application errors and a writeError
helper to translate them into JSON responses with the right HTTP status code.
The helper uses errors.As
to check whether the incoming error is already an APIError
; otherwise, it defaults to a generic internal error.
This pattern keeps handlers short and focused, while ensuring clients consistently receive clear, structured responses.
Database operations
Database operations often return driver-specific errors such as sql.ErrNoRows
that need careful handling.
Here's a simplified example showing how to wrap, propagate, and inspect errors using Go's errors.Is
and sentinel errors:
package main
import (
"database/sql"
"errors"
"fmt"
"log"
)
// User represents a user in the system
type User struct {
ID int
Name string
Email string
}
// Sentinel errors
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateUser = errors.New("duplicate user")
)
// UserRepository handles user database operations
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(id int) (*User, error) {
user := &User{}
err := r.db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id).
Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d not found: %w", id, ErrUserNotFound)
}
return nil, fmt.Errorf("failed to query user: %w", err)
}
return user, nil
}
func (r *UserRepository) CreateUser(user *User) error {
_, err := r.db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", user.Name, user.Email)
if err != nil {
if isDuplicateKeyError(err) {
return fmt.Errorf("user with email %s already exists: %w", user.Email, ErrDuplicateUser)
}
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
// Stub function for duplicate check
func isDuplicateKeyError(err error) bool { return false }
func main() {
var db *sql.DB // pretend this is initialized
repo := &UserRepository{db: db}
user, err := repo.GetUser(1)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
log.Printf("User not found: %v", err)
} else {
log.Printf("Database error: %v", err)
}
return
}
fmt.Printf("Found user: %+v\n", user)
}
This example demonstrates how to handle database errors concisely.
The GetUser
function returns wrapped errors with context and uses errors.Is
to check for the sentinel sql.ErrNoRows
.
The CreateUser
function, similarly, wraps errors and shows how you could handle specific database constraints, like duplicate keys.
The main
function illustrates how to call the repository methods and handle errors appropriately by checking for the domain-specific sentinel errors we defined.
By keeping the code flat and focused, you can see idiomatic Go practices for propagating and inspecting errors without unnecessary boilerplate.
Goroutines and Error Handling
Concurrent code needs explicit error collection. A common pattern is to send results (including errors) through channels:
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Result represents the result of an operation
type Result struct {
Value int
Error error
}
// worker performs work and sends results through a channel
func worker(ctx context.Context, id int, jobs <-chan int, results chan<- Result) {
for {
select {
case job, ok := <-jobs:
if !ok {
return // Jobs channel closed
}
// Simulate work that might fail
if job%3 == 0 {
results <- Result{
Value: 0,
Error: fmt.Errorf("worker %d: job %d failed (divisible by 3)", id, job),
}
} else {
// Simulate some processing time
time.Sleep(100 * time.Millisecond)
results <- Result{
Value: job * job,
Error: nil,
}
}
case <-ctx.Done():
fmt.Printf("Worker %d cancelled: %v\n", id, ctx.Err())
return
}
}
}
// main demonstrates error handling with goroutines
func main() {
const numWorkers = 3
const numJobs = 10
jobs := make(chan int, numJobs)
results := make(chan Result, numJobs)
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Start workers
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
worker(ctx, workerID, jobs, results)
}(w)
}
// Send jobs
go func() {
defer close(jobs)
for j := 1; j <= numJobs; j++ {
select {
case jobs <- j:
case <-ctx.Done():
fmt.Println("Job sending cancelled")
return
}
}
}()
// Collect results
go func() {
wg.Wait()
close(results)
}()
// Process results and handle errors
var successCount, errorCount int
for result := range results {
if result.Error != nil {
fmt.Printf("❌ Error: %v\n", result.Error)
errorCount++
} else {
fmt.Printf("✅ Result: %d\n", result.Value)
successCount++
}
}
fmt.Printf("\nSummary: %d successful, %d failed\n", successCount, errorCount)
}
In this example, we define a Result
struct to encapsulate both the value and any error from a worker.
The worker
function processes jobs and sends results through a channel.
The main
function sets up the job and result channels, starts multiple workers, and collects results while handling errors appropriately.
For a broader intro to concurrency, check out Go Concurrency Patterns: Goroutines and Channels.
Error Handling Best Practices
Now that we've covered the fundamentals and seen practical examples, let's summarize some best practices for error handling in Go.
1. Always check errors
Never ignore errors unless you have a specific reason:
// ❌ Bad
result, _ := aFunction()
// ✅ Good
result, err := aFunction()
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
2. Provide context in error messages
Error messages should be descriptive and provide context about what operation failed. They should also provide actionable information so that the caller can understand and potentially fix the issue.
// ❌ Bad
return errors.New("failed")
// ✅ Good
return fmt.Errorf("failed to process blog.marcnuri.com user %s: invalid email format", userID)
Note
Error message should be lowercase since they are often wrapped into larger sentences.
3. Use error wrapping for call chains
Preserve the original error while adding context:
func processUser(userID string) error {
user, err := getUser(userID)
if err != nil {
return fmt.Errorf("failed to process blog.marcnuri.com user %s: %w", userID, err)
}
// ... rest of the function
return nil
}
Note the use of %w
in fmt.Errorf
to wrap the original error.
4. Create custom error types for domain-specific errors
Use custom domain error types when you need to convey specific error conditions that callers might want to check for:
type UserNotFoundError struct {
UserID string
}
func (e *UserNotFoundError) Error() string {
return fmt.Sprintf("blog.marcnuri.com user %s not found", e.UserID)
}
5. Don't panic in libraries
Libraries should return errors, not panic. Reserve panic for truly exceptional situations:
// ❌ Bad (in a library)
func divide(n, d int) int {
if d == 0 {
panic("division by zero")
}
return n / d
}
// ✅ Good
func divide(n, d int) (int, error) {
if d == 0 {
return 0, errors.New("division by zero")
}
return n / d, nil
}
6. Use errors.Is and errors.As for error checking
Use these functions to check for specific error values or types in wrapped errors and act accordingly:
// Check for specific error values
if errors.Is(err, ErrUserNotFound) {
// Handle user not found
}
// Extract specific error types
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// Handle validation error
}
Debugging and Logging Errors
Effective logging is crucial for diagnosing issues in production. Let's see how to log errors effectively to improve the debugging experience.
Structured logging
Use structured logging to make error debugging easier:
package main
import (
"errors"
"log/slog"
"os"
)
func processUser(userID string) error {
return errors.New("database connection failed")
}
func main() {
// Create structured logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Example error scenario
userID := "Aitana"
err := processUser(userID)
if err != nil {
logger.Error("Failed to process blog.marcnuri.com user",
"user_id", userID,
"error", err.Error(),
"operation", "user_processing",
)
}
}
In this example, we use Go's slog
package to create a structured logger that outputs JSON logs.
When an error occurs in processUser
, we log the error along with relevant context such as the user ID and operation name.
This structured approach makes it easier to filter and analyze logs, especially in production environments.
The output (formatted) will look like this:
{
"time":"2015-10-21T04:29:00Z",
"level":"ERROR",
"msg":"Failed to process blog.marcnuri.com user",
"user_id":"Aitana",
"error":"database connection failed",
"operation":"user_processing"
}
Error stack traces
In general, you can rely on wrapping and context to trace errors. However, in complex scenarios, capturing stack traces can help finding the root cause of errors.
Stack-tracing is not directly supported in Go's standard library. In the example below, we implement a simple custom error type that captures the stack trace when the error is created.
package main
import (
"fmt"
"runtime"
)
// ErrorWithStack wraps an error with stack trace information
type ErrorWithStack struct {
Err error
Stack []uintptr
}
func (e *ErrorWithStack) Error() string {
return e.Err.Error()
}
func (e *ErrorWithStack) Unwrap() error {
return e.Err
}
// StackTrace returns the stack trace as strings
func (e *ErrorWithStack) StackTrace() []string {
frames := runtime.CallersFrames(e.Stack)
var trace []string
for {
frame, more := frames.Next()
trace = append(trace, fmt.Sprintf("%s:%d %s", frame.File, frame.Line, frame.Function))
if !more {
break
}
}
return trace
}
// NewErrorWithStack creates an error with stack trace
func NewErrorWithStack(err error) *ErrorWithStack {
const depth = 32
var pcs [depth]uintptr
n := runtime.Callers(2, pcs[:])
return &ErrorWithStack{
Err: err,
Stack: pcs[0:n],
}
}
func functionA() error {
return functionB()
}
func functionB() error {
return functionC()
}
func functionC() error {
return NewErrorWithStack(errors.New("something went wrong"))
}
func main() {
err := functionA()
if err != nil {
fmt.Printf("Error: %v\n", err)
var stackErr *ErrorWithStack
if errors.As(err, &stackErr) {
fmt.Println("\nStack trace:")
for _, frame := range stackErr.StackTrace() {
fmt.Printf(" %s\n", frame)
}
}
}
}
In this example, we define an ErrorWithStack
type that captures the stack trace when the error is created by using the runtime.Callers
function.
In the main
function, we simulate a series of function calls that eventually return an error wrapped with a stack trace.
When we detect that the error is of type ErrorWithStack
, we print the stack trace to help diagnose where the error originated.
Caution
Stack traces can be expensive to capture, so reserve them for debugging builds or critical failures.
The output will look like this:
Error: something went wrong
Stack trace:
/home/blog.marcnuri.com/stack_trace.go:34 main.functionC
/home/blog.marcnuri.com/stack_trace.go:29 main.functionB
/home/blog.marcnuri.com/stack_trace.go:24 main.functionA
/home/blog.marcnuri.com/stack_trace.go:40 main.main
/usr/local/go/src/runtime/proc.go:225 runtime.main
/usr/local/go/src/runtime/asm_amd64.s:1371 runtime.goexit
Conclusion
In this post, I've shown you how to handle errors explicitly, create custom error types, wrap and unwrap errors, and apply these patterns in various contexts such as web applications, database operations, and concurrent programming.
Effective error handling is crucial for building robust Go applications. By following these guidelines and Go's error handling philosophy and best practices, you'll be able to write Go code that gracefully handles errors, making your applications more resilient and easier to debug.
Remember, error handling is a design decision, it shapes the stability of your application and the experience of the next developer who maintains your code.