4 min read
On this page

Error Handling Patterns

Beyond the basics of if err != nil, Go developers have developed patterns for keeping error handling clean, managing cleanup, dealing with panics, collecting multiple errors, and handling errors across goroutines. These patterns come from real production code, not textbooks.

The Happy Path: Minimize Nesting

Go's error handling style favors early returns. Handle the error case first, then continue with the success path. The main logic flows down the left edge of the function without nesting.

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

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

    if err := config.Validate(); err != nil {
        return fmt.Errorf("invalid config: %w", err)
    }

    return applyConfig(config)
}

The rule is simple: handle errors immediately, return early, and keep the happy path at the top level of indentation.

Early Returns

Early returns eliminate else clauses. Each check acts as a guard that exits the function if the condition is not met.

func (s *Service) UpdateUser(ctx context.Context, id string, req UpdateRequest) error {
    if id == "" {
        return errors.New("user id is required")
    }

    if err := req.Validate(); err != nil {
        return fmt.Errorf("invalid request: %w", err)
    }

    user, err := s.repo.GetUser(ctx, id)
    if err != nil {
        return fmt.Errorf("fetching user %s: %w", id, err)
    }

    if !user.Active {
        return fmt.Errorf("user %s is deactivated", id)
    }

    user.Apply(req)
    if err := s.repo.SaveUser(ctx, user); err != nil {
        return fmt.Errorf("saving user %s: %w", id, err)
    }

    return nil
}

By the time you reach user.Apply(req), you know: the ID is present, the request is valid, the user exists, and the user is active. Each guard clause eliminated a failure condition.

defer for Cleanup

defer schedules a function call to run when the enclosing function returns. It is Go's answer to finally blocks and destructors.

func queryUsers(ctx context.Context, db *sql.DB) ([]User, error) {
    rows, err := db.QueryContext(ctx, "SELECT id, name, email FROM users")
    if err != nil {
        return nil, fmt.Errorf("querying users: %w", err)
    }
    defer rows.Close()  // Always runs, even if we return an error below

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
            return nil, fmt.Errorf("scanning user row: %w", err)
        }
        users = append(users, u)
    }

    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("iterating user rows: %w", err)
    }

    return users, nil
}

Deferred calls execute in LIFO order (last deferred, first executed):

func example() {
    fmt.Println("start")
    defer fmt.Println("first defer")
    defer fmt.Println("second defer")
    fmt.Println("end")
}
start
end
second defer
first defer

Capturing Errors from defer

When a deferred call can fail (like closing a file after writing), use a named return to capture the error:

func writeData(path string, data []byte) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("creating %s: %w", path, err)
    }
    defer func() {
        closeErr := f.Close()
        if err == nil {
            err = closeErr
        }
    }()

    if _, err := f.Write(data); err != nil {
        return fmt.Errorf("writing to %s: %w", path, err)
    }
    return nil
}

Table-Driven Error Handling

When you have multiple validation checks, a table-driven approach reduces repetition.

func validateOrder(o Order) error {
    checks := []struct {
        ok  bool
        msg string
    }{
        {o.CustomerID != "", "customer ID is required"},
        {len(o.Items) > 0, "order must have at least one item"},
        {o.Total > 0, "order total must be positive"},
        {o.ShippingAddress != "", "shipping address is required"},
        {o.Total < 100000, "order total exceeds maximum"},
    }

    for _, check := range checks {
        if !check.ok {
            return errors.New(check.msg)
        }
    }
    return nil
}

For validation that collects all errors at once, use errors.Join (Go 1.20+) to combine a []error into a single error value.

panic & recover

panic stops normal execution, runs deferred functions, and crashes the program. recover catches a panic inside a deferred function. You should almost never use either.

When panic Is Appropriate

  • Programmer errors during initialization. If your program cannot start because a required environment variable is missing or a regex does not compile, panic is reasonable.
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// MustCompile panics if the regex is invalid.
// This is fine: an invalid regex is a programmer error, not a runtime condition.
  • Truly impossible states. If your code reaches a state that should be impossible given the program's invariants, a panic is appropriate because continuing would cause worse problems.

When panic Is Not Appropriate

  • Network errors, file not found, invalid user input, database timeouts -- anything that could happen during normal operation. These are errors, not panics.

recover

recover can only be called inside a deferred function. It stops the panic and returns the panic value.

func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic during division: %v", r)
        }
    }()
    return a / b, nil  // Panics if b == 0
}

The main use of recover is in HTTP servers and similar infrastructure where one bad request should not crash the whole process. The standard library's net/http server recovers from panics in handlers automatically.

The Multierr Pattern

When running multiple independent operations, you may want to collect all errors rather than stopping at the first one.

func validateConfig(cfg Config) error {
    var errs []error

    if cfg.Port < 1 || cfg.Port > 65535 {
        errs = append(errs, fmt.Errorf("invalid port: %d", cfg.Port))
    }

    if cfg.Host == "" {
        errs = append(errs, errors.New("host is required"))
    }

    if cfg.Timeout <= 0 {
        errs = append(errs, errors.New("timeout must be positive"))
    }

    if _, err := url.Parse(cfg.DatabaseURL); err != nil {
        errs = append(errs, fmt.Errorf("invalid database URL: %w", err))
    }

    return errors.Join(errs...)
}
err := validateConfig(cfg)
if err != nil {
    fmt.Println(err)
}
invalid port: 0
host is required
timeout must be positive

Errors in Goroutines

Goroutines cannot return errors to their caller. You need explicit mechanisms to communicate errors back.

The golang.org/x/sync/errgroup package is the standard solution for running goroutines and collecting the first error.

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, url := range urls {
        g.Go(func() error {
            req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
            if err != nil {
                return fmt.Errorf("creating request for %s: %w", url, err)
            }

            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return fmt.Errorf("fetching %s: %w", url, err)
            }
            defer resp.Body.Close()

            if resp.StatusCode != http.StatusOK {
                return fmt.Errorf("%s returned status %d", url, resp.StatusCode)
            }
            return nil
        })
    }

    return g.Wait()  // Returns the first non-nil error
}

errgroup.WithContext creates a derived context that is cancelled when any goroutine returns an error. This lets other goroutines exit early rather than doing unnecessary work. Use g.SetLimit(n) to cap the number of concurrent goroutines.

Common Pitfalls

  • Swallowing errors in goroutines. If a goroutine encounters an error and has no way to report it (no channel, no errgroup), the error is silently lost. Always provide a path for errors to travel back.
  • Using panic for control flow. Panic is not an exception. Do not use it for expected error conditions. It exists for unrecoverable programmer mistakes.
  • Recovering from panics too broadly. A recover at the top of every function masks real bugs. Use it only at well-defined boundaries (HTTP handlers, plugin systems).
  • Not cancelling on first error. When multiple goroutines are running and one fails, the others should stop. Use errgroup.WithContext to propagate cancellation automatically.
  • Leaking goroutines on error. If a goroutine is blocked sending on a channel and nothing is reading, the goroutine leaks. Use buffered channels sized to the number of goroutines.
// Bad: if we return early, the goroutine blocks on send forever
errc := make(chan error)

// Good: goroutine can always send, even if nobody reads
errc := make(chan error, 1)

Key Takeaways

  • Keep the happy path along the left margin. Use early returns to handle errors, then continue with the success case.
  • Use defer for cleanup (closing files, releasing locks, closing database rows). It runs no matter how the function exits.
  • panic is for programmer errors and truly impossible states. Production error handling uses error values.
  • recover belongs at infrastructure boundaries (HTTP servers, plugin hosts), not scattered through application code.
  • Use errors.Join (Go 1.20+) to collect multiple errors from validation or parallel operations.
  • For errors in goroutines, use channels or errgroup. The errgroup package handles concurrency limiting, context cancellation, and error collection in one API.
  • Always provide a path for goroutine errors to reach the caller. Silent error loss is the worst kind of bug.