Introduction
Go’s approach to error handling is explicit and deliberate โ functions return errors as values, and callers must handle them. This design makes error paths visible in code, unlike exceptions which can be invisible. This guide covers Go’s error handling patterns from basics to production-grade techniques.
The Basics: Error as a Return Value
// Functions that can fail return (result, error)
data, err := os.ReadFile("config.json")
if err != nil {
log.Fatal("failed to read config:", err)
}
The error interface is simple:
type error interface {
Error() string
}
Any type implementing Error() string satisfies the interface.
Creating Errors
import "errors"
import "fmt"
// Simple string error
err := errors.New("something went wrong")
// Formatted error
err = fmt.Errorf("user %d not found", userID)
// Sentinel errors (package-level variables)
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
Error Wrapping (Go 1.13+)
Wrap errors to add context while preserving the original for inspection:
func openDatabase(path string) (*sql.DB, error) {
db, err := sql.Open("postgres", path)
if err != nil {
// %w wraps the error โ preserves it for errors.Is/As
return nil, fmt.Errorf("openDatabase: %w", err)
}
return db, nil
}
func initApp() error {
db, err := openDatabase(os.Getenv("DATABASE_URL"))
if err != nil {
return fmt.Errorf("initApp: %w", err)
}
// ...
return nil
}
The error chain: initApp: openDatabase: dial tcp: connection refused
errors.Is: Check Error Type in Chain
var ErrNotFound = errors.New("not found")
func getUser(id int) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("getUser(%d): %w", id, ErrNotFound)
}
return user, nil
}
// Caller can check for specific error type anywhere in the chain
user, err := getUser(42)
if errors.Is(err, ErrNotFound) {
// handle not found
}
errors.As: Extract Specific Error Type
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: %s %s", e.Field, e.Message)
}
func validateUser(u User) error {
if u.Email == "" {
return fmt.Errorf("validateUser: %w", &ValidationError{
Field: "email",
Message: "is required",
})
}
return nil
}
// Extract the ValidationError from the chain
err := validateUser(user)
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Field %q: %s\n", valErr.Field, valErr.Message)
}
Custom Error Types
// HTTP-aware error type
type APIError struct {
StatusCode int
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err != nil {
return fmt.Sprintf("API error %d: %s: %v", e.StatusCode, e.Message, e.Err)
}
return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}
// Implement Unwrap for errors.Is/As to work through the chain
func (e *APIError) Unwrap() error {
return e.Err
}
// Usage
func fetchUser(id int) (*User, error) {
resp, err := http.Get(fmt.Sprintf("/api/users/%d", id))
if err != nil {
return nil, &APIError{StatusCode: 0, Message: "request failed", Err: err}
}
if resp.StatusCode == 404 {
return nil, &APIError{StatusCode: 404, Message: "user not found"}
}
// ...
}
// Caller
user, err := fetchUser(42)
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case 404:
// handle not found
case 401:
// handle unauthorized
default:
log.Printf("API error: %v", apiErr)
}
}
Sentinel Errors Pattern
Define package-level error variables for errors callers need to check:
package store
import "errors"
// Sentinel errors โ exported for callers to check
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate entry")
ErrForbidden = errors.New("forbidden")
)
func (s *Store) GetUser(id int) (*User, error) {
var user User
err := s.db.QueryRow("SELECT * FROM users WHERE id = $1", id).Scan(&user)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("GetUser(%d): %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("GetUser(%d): %w", id, err)
}
return &user, nil
}
// Caller
user, err := store.GetUser(42)
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
log.Printf("GetUser: %v", err)
return
}
Panic and Recover
Panic is for unrecoverable errors โ programming bugs, not expected failures:
// Use panic for programming errors
func divide(a, b int) int {
if b == 0 {
panic("divide by zero") // programming error
}
return a / b
}
// Use recover in top-level handlers to prevent crashes
func safeHandler(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v\n%s", err, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
h(w, r)
}
}
Rule: Never use panic for expected errors (file not found, network timeout, invalid input). Use error returns.
Defer for Cleanup
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("processFile: %w", err)
}
defer f.Close() // always runs, even if we return early
// Process...
return nil
}
// Named return values + defer for error annotation
func writeFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("writeFile: %w", err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("writeFile close: %w", cerr)
}
}()
_, err = f.Write(data)
return
}
Error Handling in HTTP Handlers
// Helper to reduce boilerplate
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string { return e.Message }
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := f(w, r); err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
http.Error(w, appErr.Message, appErr.Code)
} else {
log.Printf("unhandled error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// Clean handler โ just return errors
func getUser(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return &AppError{Code: 400, Message: "invalid user ID"}
}
user, err := store.GetUser(id)
if errors.Is(err, store.ErrNotFound) {
return &AppError{Code: 404, Message: "user not found"}
}
if err != nil {
return fmt.Errorf("getUser: %w", err)
}
return json.NewEncoder(w).Encode(user)
}
// Register
http.Handle("/users/{id}", HandlerFunc(getUser))
Best Practices
// 1. Add context when wrapping
return fmt.Errorf("createOrder(userID=%d): %w", userID, err)
// 2. Don't wrap sentinel errors with extra context that loses the type
// Bad:
return fmt.Errorf("not found: %s", ErrNotFound) // loses wrapping
// Good:
return fmt.Errorf("user %d: %w", id, ErrNotFound)
// 3. Log at the boundary, not at every level
// Bad: log at every level (duplicate logs)
// Good: log once at the top level, propagate errors up
// 4. Don't ignore errors
result, _ := doSomething() // BAD
result, err := doSomething() // GOOD
if err != nil { ... }
// 5. Use errors.Is/As, not type assertions
// Bad:
if err.(*os.PathError) != nil { ... }
// Good:
var pathErr *os.PathError
if errors.As(err, &pathErr) { ... }
Resources
- Go Blog: Error Handling and Go
- Go Blog: Working with Errors in Go 1.13
- Go Docs: errors package
- Dave Cheney: Don’t just check errors, handle them gracefully
Comments