06 - Error Handling

📋 Jump to Takeaways

Go doesn't have exceptions. No try/catch, no throw. Errors are values. You return them, check them, wrap them. It feels tedious at first. Then you realize you always know exactly where an error came from and why. That's the trade.

The Basics

Functions that can fail return an error as the last value:

f, err := os.Open("config.json")
if err != nil {
    // handle it
}

You've seen this a hundred times already. The question is: what do you do inside that if block?

Wrapping Errors with fmt.Errorf

Don't just return the raw error. Add context:

func loadConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, fmt.Errorf("loadConfig: %w", err)
    }

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

The %w verb wraps the original error. The caller gets your message plus the original. This is how you build an error chain.

Sentinel Errors

Sometimes you need to check for a specific error, not just "something went wrong." A sentinel error is a package-level variable that represents a known condition — like "not found" or "already exists." The name comes from the idea of a sentinel value: a special value you compare against to detect a specific situation. The standard library uses them everywhere: io.EOF, sql.ErrNoRows, context.Canceled.

Define your own:

var ErrNotFound = errors.New("bookmark not found")
var ErrDuplicate = errors.New("bookmark already exists")

Return them from your code:

func (s *BookmarkStore) GetByID(id string) (Bookmark, error) {
    // ... query database ...
    if row == nil {
        return Bookmark{}, ErrNotFound
    }
    return bookmark, nil
}

errors.Is and errors.As

errors.Is checks if any error in the chain matches a sentinel:

bookmark, err := store.GetByID(id)
if errors.Is(err, ErrNotFound) {
    writeError(w, http.StatusNotFound, "bookmark not found")
    return
}

This works even if the error was wrapped with fmt.Errorf("%w", ...). It walks the entire chain.

errors.As extracts a specific error type:

var validErr *ValidationError
if errors.As(err, &validErr) {
    writeError(w, http.StatusBadRequest, validErr.Field+": "+validErr.Message)
    return
}

Use errors.Is for sentinel values. Use errors.As for custom error types.

The difference: errors.Is answers "is this the exact error I'm looking for?" — you're comparing values. errors.As answers "is this error a type that has extra information I can read?" — you're extracting a struct so you can access its fields (like validErr.Field above).

Custom Error Types

When you need more than a string:

type ValidationError struct {
    Field   string
    Message string
}

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

Return it as a pointer so errors.As works:

func validateBookmark(b Bookmark) error {
    if b.URL == "" {
        return &ValidationError{Field: "url", Message: "is required"}
    }
    return nil
}

The TraceError Pattern

Here's the problem. You get an error in your logs: bookmark not found. Great. But where? Which function? Which file? Which line?

The runtime.Caller function gives you that. Build a TraceError that captures location automatically:

package errx

import (
    "fmt"
    "runtime"
)

type TraceError struct {
    Err  error
    File string
    Line int
    Func string
}

func (e *TraceError) Error() string {
    return fmt.Sprintf("%s:%d [%s] %s", e.File, e.Line, e.Func, e.Err.Error())
}

func (e *TraceError) Unwrap() error {
    return e.Err
}

func WrapError(err error) error {
    if err == nil {
        return nil
    }
    pc, file, line, _ := runtime.Caller(1)
    fn := runtime.FuncForPC(pc).Name()
    return &TraceError{
        Err:  err,
        File: file,
        Line: line,
        Func: fn,
    }
}

The Unwrap method is critical. Without it, errors.Is and errors.As can't see through your wrapper.

Use it anywhere you handle an error:

func (s *BookmarkStore) GetByID(id string) (Bookmark, error) {
    row := s.db.QueryRow("SELECT id, url, title FROM bookmarks WHERE id = $1", id)
    var b Bookmark
    if err := row.Scan(&b.ID, &b.URL, &b.Title); err != nil {
        return Bookmark{}, errx.WrapError(err)
    }
    return b, nil
}

Now your error includes store.go:24 [bookmarks.(*BookmarkStore).GetByID] sql: no rows in result set. You know exactly where it happened.

Don't Panic

panic exists in Go. Don't use it for error handling. A panic unwinds the stack and crashes the program (unless recovered). It's for truly unrecoverable situations: a nil map that should never be nil, a programmer bug.

// ❌ Bad: don't do this
func GetConfig() Config {
    cfg, err := loadConfig("config.json")
    if err != nil {
        panic(err) // crashes the server
    }
    return cfg
}

// Good: return the error
func GetConfig() (Config, error) {
    return loadConfig("config.json")
}

The only acceptable place for panic is during initialization, when the program literally cannot start. Even then, log.Fatal is usually better because it's explicit.

Applying to Our Project

Create an errx package with WrapError. Use sentinel errors for domain logic. Handle errors in handlers with proper HTTP status codes:

func getBookmark(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    bookmark, err := store.GetByID(id)
    if errors.Is(err, ErrNotFound) {
        writeError(w, http.StatusNotFound, "bookmark not found")
        return
    }
    if err != nil {
        slog.Error("failed to get bookmark", "err", err, "id", id)
        writeError(w, http.StatusInternalServerError, "internal error")
        return
    }

    writeJSON(w, http.StatusOK, bookmark)
}

Pattern: check for known errors first, then handle the unknown. Log the unknown ones with full context. Never expose internal error details to the client.

Key Takeaways

  • Errors are values. Return them, check them, wrap them with fmt.Errorf and %w
  • Sentinel errors (var ErrNotFound = errors.New(...)) let callers check for specific conditions
  • errors.Is walks the chain for sentinels, errors.As extracts typed errors
  • TraceError with runtime.Caller gives you file, line, and function name for free
  • Always implement Unwrap() on custom error types so the chain works
  • Don't panic. Return errors. Let the caller decide what to do

🚀 Ready to run?

Complete runnable examples for this lesson.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy