Back to blog

Sunday, August 16, 2026

Go panic/recover vs Returning an Error: When to Use Which

Title card for 'Go panic/recover vs Returning an Error: When to Use Which' with a short code example

Return an error when the failure is expected and the caller can decide what to do. Call panic when continuing would be a bug or the process cannot start. Use recover only in a deferred function, usually at a package or request boundary, to turn an unexpected unwind into a logged failure, not as a general exception system.

The failure

This helper panics because a config file is missing. A server that wanted a 400 or a fallback path instead dies.

package main

import (
    "os"
)

func readConfig(path string) []byte {
    data, err := os.ReadFile(path)
    if err != nil {
        panic(err) // I/O is expected; the caller never gets a vote
    }
    return data
}

func main() {
    _ = readConfig("config.json")
}

The opposite mistake is swallowing a contract violation as error when the call is always a programmer bug (MustCompile with a hardcoded pattern, nil where the API forbids it). Callers then check err forever for something that should have failed the test run.

recover() in the function body, not in defer, does nothing. An unrecovered panic in any goroutine still kills the whole process.

Why this happens

Go does not use exceptions for ordinary control flow. error is the conversation with the caller. panic stops the current goroutine, runs defers, and unwinds until someone recovers or the program exits.

That split matches two buckets:

  • Outside world: missing file, timeout, bad JSON from a client. Return error.
  • Broken invariants: index out of range, nil deref, Must* on a constant that does not compile, missing PORT at startup. panic (or let the runtime panic).

Libraries that panic internally (encoding/json style) still recover at the exported function and return error. A panic that crosses a public API is a crash the user did not opt into.

The fix

Return the error. Panic only when crash-on-the-spot is the product.

func readConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config %s: %w", path, err)
    }
    return data, nil
}

For a hardcoded regexp, regexp.MustCompile is correct: failure means the source is wrong. Prefix those wrappers Must.

At a handler or worker, recover so one bug does not take the process:

func handle(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if rec := recover(); rec != nil {
            log.Printf("handler panic: %v\n%s", rec, debug.Stack())
            http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        }
    }()
    // fetchData returns error; do not panic on it
}

Do not recover and continue the same operation as if state were fine. Log, fail that unit of work, move on.