Back to blog

Go Deep Dive: Error Handling Patterns

gogolangprogrammingbackenderrors
Go Deep Dive: Error Handling Patterns

Error handling is where Go diverges most sharply from other languages. No exceptions. No try/catch. Just values — and that's exactly the point.

In Phase 2 you learned the basics: functions return (value, error) and you check if err != nil. That's correct, but it's only the foundation. In production code you need to wrap errors to preserve context, inspect errors to make decisions, and design custom error types that carry structured information.

This deep dive covers all of it.

What You'll Learn

✅ Why Go chose errors-as-values over exceptions
✅ Sentinel errors and when to use them
✅ Custom error types with additional context
✅ Error wrapping with fmt.Errorf("%w", err)
✅ Unwrapping with errors.Is and errors.As
✅ The error chain and how it works internally
✅ Production patterns: logging, HTTP error responses, typed errors
✅ Common mistakes and how to avoid them


Part 1: Why Errors-as-Values?

In Python or Java, you throw an exception and it unwinds the call stack invisibly. The caller at the top of the stack can catch it — or ignore it — and the compiler doesn't care. This leads to undocumented failure modes.

In Go, every error is an explicit return value. The compiler forces you to handle (or explicitly ignore) errors:

// The `error` interface — built into Go, just two words
type error interface {
    Error() string
}
 
// Every function that can fail returns error as the last value
func readFile(path string) ([]byte, error) {
    // ...
}
 
// Caller MUST handle it
data, err := readFile("config.json")
if err != nil {
    // handle it: log, wrap, return, or panic — your choice
    return fmt.Errorf("loading config: %w", err)
}

Benefits:

  • Failures are visible at the call site — no hidden control flow
  • Static analysis tools can find unhandled errors (errcheck, staticcheck)
  • The compiler doesn't enforce handling, but go vet and linters do

The cost: more lines of code. Go programmers accept this explicitly. The robustness trade-off is worth it.


Part 2: The Three Ways to Create Errors

1. errors.New — Simple, Static Messages

import "errors"
 
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
 
func getUser(id int) (*User, error) {
    if id <= 0 {
        return nil, errors.New("user ID must be positive")
    }
    // ...
}

Use errors.New for one-off errors. Use package-level var declarations for errors you'll check against (errors.Is).

2. fmt.Errorf — Formatted Messages

import "fmt"
 
func getUser(id int) (*User, error) {
    user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return nil, fmt.Errorf("querying user %d: %w", id, err)
    }
    if user == nil {
        return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
    }
    return user, nil
}

The %w verb wraps the original error — preserving the full chain for unwrapping later. This is critical for debugging.

3. Custom Error Types — Structured Data

When the caller needs more than a string, create a type:

type ValidationError struct {
    Field   string
    Message string
}
 
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on field %q: %s", e.Field, e.Message)
}
 
func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be non-negative"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "unrealistic value"}
    }
    return nil
}

The caller can then inspect the type with errors.As to access structured fields like Field.


Part 3: Sentinel Errors

A sentinel error is a package-level error variable used for comparison. It's the Go equivalent of a specific exception class:

package store
 
import "errors"
 
// Exported sentinel errors — callers can check against these
var (
    ErrNotFound    = errors.New("not found")
    ErrDuplicate   = errors.New("duplicate entry")
    ErrUnauthorized = errors.New("unauthorized")
)
// Caller uses errors.Is to check
user, err := store.GetUser(id)
if errors.Is(err, store.ErrNotFound) {
    // Return 404 to the HTTP client
    http.Error(w, "User not found", http.StatusNotFound)
    return
}
if err != nil {
    // Unknown error — return 500
    http.Error(w, "Internal server error", http.StatusInternalServerError)
    log.Printf("unexpected error getting user %d: %v", id, err)
    return
}

Standard library examples you already use:

  • io.EOF — signals end of stream
  • sql.ErrNoRows — no rows returned from a query
  • context.Canceled, context.DeadlineExceeded — context states

When to use sentinel errors

SituationUse sentinel?
Caller needs to branch on a specific error✅ Yes
Error is a common, expected condition (not a bug)✅ Yes
Error is internal to the package❌ No
Error needs structured data❌ No — use custom type instead

Part 4: Error Wrapping — The Error Chain

Error wrapping is how you add context without losing the original cause.

// Without wrapping — original error is lost
return nil, fmt.Errorf("operation failed")  // 😞 caller can't see why
 
// With wrapping — full chain preserved
return nil, fmt.Errorf("saving user: %w", err)  // ✅

The %w verb creates an error that wraps err. When you print it, you see the full message. When you unwrap it, you get err back.

Building a chain

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("ReadConfig: reading file %q: %w", path, err)
    }
 
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("ReadConfig: parsing JSON from %q: %w", path, err)
    }
 
    return &cfg, nil
}
 
func StartServer(cfgPath string) error {
    cfg, err := ReadConfig(cfgPath)
    if err != nil {
        return fmt.Errorf("StartServer: %w", err)
    }
    // ...
    return nil
}

When StartServer fails, the error message reads:

StartServer: ReadConfig: reading file "config.json": open config.json: no such file or directory

Each layer names itself + wraps the layer below. This is a call stack encoded in the error string — invaluable when debugging production failures.


Part 5: errors.Is and errors.As

errors.Is — Checking the Chain

errors.Is(err, target) traverses the entire error chain looking for a match — not just the outermost error:

// Even though err is wrapped multiple layers deep,
// errors.Is finds ErrNotFound in the chain
err := fmt.Errorf("handler: %w", fmt.Errorf("store: %w", ErrNotFound))
 
errors.Is(err, ErrNotFound)  // true ✅
 
// Old way — only checks outermost error
err == ErrNotFound           // false ❌ — wrapped errors don't equal

Implement Is for custom types when you want custom comparison logic:

type HTTPError struct {
    Code    int
    Message string
}
 
func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}
 
// Allow errors.Is to match on status code
func (e *HTTPError) Is(target error) bool {
    t, ok := target.(*HTTPError)
    if !ok {
        return false
    }
    return t.Code == e.Code
}
 
// Usage
var ErrNotFoundHTTP = &HTTPError{Code: 404}
 
err := fmt.Errorf("fetching profile: %w", &HTTPError{Code: 404, Message: "user not found"})
errors.Is(err, ErrNotFoundHTTP)  // true ✅ — matched by Code

errors.As — Extracting Type from the Chain

errors.As(err, &target) finds the first error in the chain that can be assigned to target:

type ValidationError struct {
    Field   string
    Message string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
 
func processRequest(input Input) error {
    if err := validate(input); err != nil {
        return fmt.Errorf("processRequest: %w", err)
    }
    return nil
}
 
// At the handler level:
err := processRequest(badInput)
 
var valErr *ValidationError
if errors.As(err, &valErr) {
    // ✅ valErr is populated with the concrete ValidationError
    fmt.Printf("Bad field: %s\n", valErr.Field)
    http.Error(w, fmt.Sprintf("Invalid %s: %s", valErr.Field, valErr.Message), http.StatusBadRequest)
    return
}

errors.As searches the chain — even if ValidationError is buried under multiple fmt.Errorf("%w") wrappers.

errors.Is vs errors.As

errors.Iserrors.As
ChecksIdentity or equalityType assignability
TargetA specific error valueA pointer to an error type
Use whenSentinel errorsCustom error types with fields
Exampleerrors.Is(err, io.EOF)errors.As(err, &valErr)

Part 6: Unwrapping Manually

errors.Unwrap(err) returns the wrapped error, or nil if there is none:

wrapped := fmt.Errorf("outer: %w", io.EOF)
inner := errors.Unwrap(wrapped)
fmt.Println(inner) // EOF

For custom types that wrap errors, implement Unwrap():

type AppError struct {
    Code    string
    Message string
    Cause   error
}
 
func (e *AppError) Error() string {
    if e.Cause != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
 
// Implement Unwrap so errors.Is/As work through this type
func (e *AppError) Unwrap() error {
    return e.Cause
}

Now errors.Is and errors.As can traverse your custom type too.


Part 7: Production Error Patterns

Pattern 1 — HTTP Error Responses

Map errors to HTTP status codes at the boundary (in the handler), not deep in business logic:

type AppError struct {
    Code     string // machine-readable code
    Message  string // human-readable message
    HTTPCode int    // derived HTTP status code
    Cause    error
}
 
func (e *AppError) Error() string {
    if e.Cause != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
 
func (e *AppError) Unwrap() error { return e.Cause }
 
// Package-level constructors
func NotFound(msg string, cause error) *AppError {
    return &AppError{Code: "NOT_FOUND", Message: msg, HTTPCode: 404, Cause: cause}
}
 
func BadRequest(msg string, cause error) *AppError {
    return &AppError{Code: "BAD_REQUEST", Message: msg, HTTPCode: 400, Cause: cause}
}
 
func Internal(msg string, cause error) *AppError {
    return &AppError{Code: "INTERNAL", Message: msg, HTTPCode: 500, Cause: cause}
}
// Handler
func getUserHandler(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))
 
    user, err := userService.GetByID(r.Context(), id)
    if err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            writeError(w, appErr.HTTPCode, appErr.Code, appErr.Message)
        } else {
            log.Printf("unexpected error: %v", err)
            writeError(w, 500, "INTERNAL", "Something went wrong")
        }
        return
    }
 
    writeJSON(w, 200, user)
}

Pattern 2 — Error Sentinel + Wrapping in Layers

// store/user.go
package store
 
var ErrNotFound = errors.New("not found")
 
func (s *Store) GetUser(ctx context.Context, id int) (*User, error) {
    var user User
    err := s.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email)
 
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("user %d: %w", id, ErrNotFound) // wrap sentinel
    }
    if err != nil {
        return nil, fmt.Errorf("querying user %d: %w", id, err)
    }
    return &user, nil
}
// service/user.go
package service
 
func (s *Service) GetProfile(ctx context.Context, userID int) (*Profile, error) {
    user, err := s.store.GetUser(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("GetProfile: %w", err) // preserve chain
    }
    // ...
    return profile, nil
}
// handler — check sentinel through the chain
profile, err := svc.GetProfile(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
    http.Error(w, "Profile not found", 404)
    return
}

The store.ErrNotFound is visible through multiple layers of wrapping. Business logic stays clean; the handler decides how to respond.

Pattern 3 — Multi-Error (multiple validations)

When you need to collect multiple errors at once:

type MultiError struct {
    Errors []error
}
 
func (m *MultiError) Error() string {
    msgs := make([]string, len(m.Errors))
    for i, e := range m.Errors {
        msgs[i] = e.Error()
    }
    return strings.Join(msgs, "; ")
}
 
func (m *MultiError) Unwrap() []error {
    return m.Errors // Go 1.20+: return slice for errors.Is/As to walk
}
 
func validateUser(u User) error {
    var errs MultiError
    if u.Name == "" {
        errs.Errors = append(errs.Errors, &ValidationError{Field: "name", Message: "required"})
    }
    if u.Email == "" {
        errs.Errors = append(errs.Errors, &ValidationError{Field: "email", Message: "required"})
    }
    if !isValidEmail(u.Email) {
        errs.Errors = append(errs.Errors, &ValidationError{Field: "email", Message: "invalid format"})
    }
    if len(errs.Errors) > 0 {
        return &errs
    }
    return nil
}

Go 1.20+ introduced errors.Join for this pattern:

var errs []error
if u.Name == "" {
    errs = append(errs, fmt.Errorf("name: required"))
}
if u.Email == "" {
    errs = append(errs, fmt.Errorf("email: required"))
}
return errors.Join(errs...) // returns nil if errs is empty

Part 8: The panic and recover — When to Use Them

panic is not exception handling. Use it only for:

  • Programming errors (nil pointer dereference, out-of-bounds index)
  • Truly unrecoverable situations at startup (missing required env vars, broken required config)
  • Initializing package-level state that must succeed
// ✅ Appropriate panic — required env var missing at startup
func mustGetEnv(key string) string {
    v := os.Getenv(key)
    if v == "" {
        panic(fmt.Sprintf("required environment variable %q is not set", key))
    }
    return v
}
 
var dbURL = mustGetEnv("DATABASE_URL") // fails fast at startup
// ✅ Appropriate recover — in HTTP middleware to survive panics
func panicRecovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("recovered from panic: %v\n%s", r, debug.Stack())
                http.Error(w, "Internal server error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}
// ❌ Inappropriate — don't use panic as flow control
func getUser(id int) *User {
    if id <= 0 {
        panic("invalid ID") // ❌ return an error instead
    }
}

Rule: If a failure can plausibly happen in production due to external conditions (bad input, network failure, missing resource), return an error. If it indicates a programming bug that should never happen in correct code, panic is acceptable.


Part 9: Common Mistakes

1. Ignoring Errors

// ❌ Ignoring errors silently
file, _ := os.Open("config.json")
defer file.Close()
 
// ✅ Handle or explicitly document why you're ignoring
file, err := os.Open("config.json")
if err != nil {
    return fmt.Errorf("opening config: %w", err)
}
defer file.Close()

Use _ to discard errors only when you've consciously decided the failure doesn't matter and documented why.

2. Wrapping Sentinels Incorrectly

// ❌ Using fmt.Errorf without %w — creates new error, breaks chain
return fmt.Errorf("not found: %v", ErrNotFound) // errors.Is won't work!
 
// ✅ Use %w to wrap
return fmt.Errorf("user %d: %w", id, ErrNotFound)

3. Wrapping Errors Multiple Times

// ❌ Adding context at every single level creates noise
func a() error { return fmt.Errorf("a: %w", b()) }
func b() error { return fmt.Errorf("b: %w", c()) }
func c() error { return fmt.Errorf("c: %w", d()) }
func d() error { return errors.New("original") }
// Result: "a: b: c: original" — too much nesting

Wrap when you're crossing a meaningful boundary (package, service, I/O operation) — not at every function call.

4. Comparing Errors with ==

// ❌ Direct comparison breaks with wrapping
if err == io.EOF { ... } // fails if err is wrapped
 
// ✅ Always use errors.Is
if errors.Is(err, io.EOF) { ... } // traverses the chain

5. Returning Generic Errors from Public APIs

// ❌ Caller can't distinguish cases
func (s *Store) GetUser(id int) (*User, error) {
    return nil, errors.New("error") // what kind of error?
}
 
// ✅ Return sentinel or typed errors from public APIs
func (s *Store) GetUser(id int) (*User, error) {
    // ...
    return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}

Summary and Key Takeaways

Creating errors:

errors.New("msg") for simple, static error values
fmt.Errorf("context: %w", err) to add context and wrap
✅ Custom types with Error() string when you need structured data

Checking errors:

errors.Is(err, target) — check against a specific error through the chain
errors.As(err, &typedErr) — extract a specific error type from the chain
✅ Never use == to compare errors — wrapping breaks it

Wrapping discipline:

✅ Wrap at meaningful boundaries: package edges, I/O, external calls
✅ Name the context: fmt.Errorf("FunctionName: doing X: %w", err)
✅ Implement Unwrap() error in custom types so the chain is traversable

Production:

✅ Define sentinel errors in packages for expected failure modes
✅ Use AppError or similar at HTTP boundaries to map errors to status codes
✅ Use errors.Join (Go 1.20+) or MultiError for collecting multiple failures
✅ Use panic only for programming bugs, never for expected runtime failures


What's Next?

Continue the Go deep dives:

Go companion posts:

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.