Error Handling

Go treats errors as values, not exceptions. Functions return an error alongside their result, and callers check it immediately. There is no try/catch.

The error Interface

type error interface {
    Error() string
}

Any type with an Error() string method is an error. The most common way to create one:

import "errors"

err := errors.New("something went wrong")
err := fmt.Errorf("failed to load user %d: %w", id, err)

The if err != nil Pattern

data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("reading config: %w", err)
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
    return fmt.Errorf("parsing config: %w", err)
}

Every function call that can fail gets its own error check. This is verbose but explicit - you always know what happens when something fails.

Tip: Wrap errors with context about what YOU were doing, not what the callee did. "reading config: %w" not "ReadFile failed: %w".

Error Wrapping with %w

fmt.Errorf with the %w verb wraps an error, preserving the original for inspection:

func loadUser(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("load user %d: %w", id, err)
    }
    return &u, nil
}

The wrapped error chain is inspectable with errors.Is and errors.As.

Sentinel Errors with errors.Is

Sentinel errors are package-level variables for expected error conditions:

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

func GetUser(id string) (*User, error) {
    u, ok := users[id]
    if !ok {
        return nil, fmt.Errorf("get user %s: %w", id, ErrNotFound)
    }
    return u, nil
}

// Caller checks with errors.Is (works through wrapping):
user, err := GetUser("abc")
if errors.Is(err, ErrNotFound) {
    http.Error(w, "user not found", http.StatusNotFound)
    return
}

Gotcha: Always use errors.Is(err, target), never err == target. The == comparison does not unwrap.

Custom Error Types with errors.As

For errors that carry structured data:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s %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
}

// Extract the typed error:
if err := ValidateAge(-1); err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("field %s: %s\n", ve.Field, ve.Message)
    }
}

Multiple Wrapping (Go 1.20+)

Wrap multiple errors with %w:

err := fmt.Errorf("operation failed: %w and %w", err1, err2)

errors.Is(err, err1) // true
errors.Is(err, err2) // true

Or join them:

err := errors.Join(err1, err2, err3)

This is useful for validation that collects multiple errors.

Error Handling Rules

  1. Handle each error exactly once. Either return it, log it, or handle it. Never log and return the same error.
// Bad: error gets logged twice (here and by caller)
if err != nil {
    log.Println("failed:", err)
    return err
}

// Good: add context and return
if err != nil {
    return fmt.Errorf("fetching user: %w", err)
}
  1. Sanitize at API boundaries. Don’t leak internal errors to external callers.
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := s.store.GetUser(r.Context(), chi.URLParam(r, "id"))
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        // Log the internal error, return a generic message
        slog.Error("get user failed", "error", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(user)
}
  1. Use defer for cleanup, not error handling.
f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close() // always runs, even on error return

When to Panic

Almost never. Panics are for unrecoverable programmer errors (out of bounds, nil dereference in impossible situations). Library code should never panic - return errors instead.

The only acceptable uses:

  • Must* wrapper functions that panic on error (for package-level initialization)
  • Truly impossible states that indicate a bug
var tmpl = template.Must(template.ParseFiles("index.html"))

Next: Interfaces | Testing