Back to Notes

Go Error Handling

Overview

Go handles errors as values, not exceptions. There's no try/catch — instead, functions return an error as the last return value, and callers check it explicitly. This makes error paths visible, forces you to think about failures, and keeps control flow linear and readable.


The error Interface

error is a built-in interface with a single method. Any type with an Error() string method satisfies it.

type error interface {
    Error() string
}

Convention: Return error as the last return value. Return zero/nil values for other results when an error occurs.

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("divide: b cannot be zero")
    }
    return a / b, nil
}

result, err := divide(10, 2)
if err != nil {
    log.Fatal(err)   // or return/handle
}
fmt.Println(result)  // 5

Creating Errors

errors.New — simple static message

Best for sentinel errors that are compared by identity.

import "errors"

var ErrNotFound    = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")

func findUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrNotFound
    }
    // ...
}

// Caller checks
if errors.Is(err, ErrNotFound) {
    // handle "not found" specifically
}

fmt.Errorf — formatted message with context

Use this to add context as you propagate errors up the call stack. Use %w to wrap the original error so it can still be inspected.

func getUser(id int) (*User, error) {
    user, err := db.QueryUser(id)
    if err != nil {
        return nil, fmt.Errorf("getUser %d: %w", id, err)
    }
    return user, nil
}

// Error message reads: "getUser 42: not found"

Custom Error Types

Define a struct implementing Error() string when you need to carry extra data with the error.

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{Field: "age", Message: "must be between 0 and 150"}
    }
    return nil
}

err := validateAge(-5)
if err != nil {
    fmt.Println(err)
    // validation failed on field "age": must be between 0 and 150
}

Error Wrapping and Unwrapping

Wrapping lets you add context while preserving the original error for inspection.

// Wrap with context
err := fmt.Errorf("handler: %w", ErrNotFound)

// errors.Is — checks if error chain contains target (works through wraps)
errors.Is(err, ErrNotFound)   // true

// errors.As — extracts a specific type from the error chain
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("field:", valErr.Field)
}

// errors.Unwrap — get the directly wrapped error
inner := errors.Unwrap(err)

Wrapping chain example:

// db error → repo error → handler error
dbErr   := errors.New("connection refused")
repoErr := fmt.Errorf("repo.GetUser: %w", dbErr)
svcErr  := fmt.Errorf("svc.GetUser: %w", repoErr)

errors.Is(svcErr, dbErr)  // true — traverses the chain

The if err != nil Pattern

The idiomatic Go way to handle errors — check immediately after every call that can fail.

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("processFile: %w", err)
    }
    defer f.Close()

    data, err := io.ReadAll(f)
    if err != nil {
        return fmt.Errorf("processFile read: %w", err)
    }

    if err := process(data); err != nil {
        return fmt.Errorf("processFile process: %w", err)
    }

    return nil
}

Sentinel Errors — Package-Level Variables

Exported package-level error variables that callers can compare against. By convention, named ErrXxx.

var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrTimeout     = errors.New("operation timed out")
)

// Usage
switch {
case errors.Is(err, ErrNotFound):
    http.Error(w, "not found", 404)
case errors.Is(err, ErrUnauthorized):
    http.Error(w, "unauthorized", 401)
default:
    http.Error(w, "internal error", 500)
}

panic — Unrecoverable Errors

panic stops normal execution, runs all deferred functions, then crashes the program. Use only for programming bugs and impossible states — not for expected failures like bad user input or missing files.

// Good use: assertion that should never fail
func mustPositive(n int) int {
    if n <= 0 {
        panic(fmt.Sprintf("mustPositive: got %d, want > 0", n))
    }
    return n
}

// Good use: package init failure
func init() {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        panic("cannot connect to database: " + err.Error())
    }
}

recover — Catching Panics

recover stops a panic and returns the panic value. Must be called directly inside a defer function. Commonly used in web servers to prevent a single request from crashing the whole server.

func safeRun(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered panic: %v", r)
        }
    }()
    fn()
    return nil
}

err := safeRun(func() {
    panic("something went wrong")
})
fmt.Println(err)  // recovered panic: something went wrong
// Middleware pattern — protect HTTP handlers from panics
func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic: %v", err)
                http.Error(w, "internal server error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

When to Use What

SituationTool
Expected failure (validation, DB miss, network)Return error
Add context as error bubbles upfmt.Errorf("context: %w", err)
Check for specific errorerrors.Is(err, ErrXxx)
Extract typed errorerrors.As(err, &target)
Fatal startup failure (bad config, missing DB)log.Fatal(err) — exits with code 1
True programming bug / impossible statepanic
Library code — never crash the callerReturn error, never panic
Web server — isolate request panicsrecover in middleware

Gotchas

GotchaDetail
Ignoring errorsresult, _ := fn() silently swallows failures — always handle or propagate
Not wrapping with %wWrapping adds context AND preserves the chain; without %w, errors.Is won't find the original
panic for expected failuresPanics from user errors (404, bad input) will crash the server — use error
recover outside deferrecover() called outside a deferred function always returns nil — doesn't catch panics
Comparing wrapped errors with ==Use errors.Is not == when errors may be wrapped