panic, recover, and Failure Modes

Updated

September 13, 2026

panic, recover, and Failure Modes

panic is for broken invariants: the program is wrong, not the user. recover is for a process or goroutine boundary that must not take the rest of the desk down with it. The boring default for a missing table, a bad request, or a closed card reader is error.

Mental model

  • panic(v) stops the current goroutine, runs deferred functions, then crashes the process if nothing recovers.
  • recover() is only useful inside a deferred function. It returns the panic value, or nil if there is no panic.
  • Recovering in the middle of business logic hides bugs. Recovering in a worker so one bad ticket does not kill the shift is the intended use.

Failure modes at a desk:

Situation Tool
Caller passed table 0 error
JSON from the network is junk error
A length is negative because your own code subtracted wrong panic (or a test that should have caught it)
A worker goroutine should die without the process dying recover at that goroutine’s top defer

Worked examples

Case 1: A panic for a true invariant

A shift roster with zero names is not an input error in this program: main builds the roster. If it is empty, the author made a mistake. Panic is a loud crash.

Save as invariant.go:

// invariant.go
package main

import "fmt"

func mustLead(roster []string) string {
    if len(roster) == 0 {
        panic("empty shift roster")
    }
    return roster[0]
}

func main() {
    fmt.Println("lead:", mustLead([]string{"Amina", "Bo"}))
    fmt.Println("lead:", mustLead(nil))
}

Run:

go run invariant.go

Output (first lines; a stack trace follows, then the process exits 2):

lead: Amina
panic: empty shift roster

The first call is fine. The second is a bug. Do not recover here just to keep main pretty. Fix the roster.

Case 2: Recover at a worker boundary

Each ticket runs in its own worker. A panic in one worker must not kill the others. recover lives in a defer at the top of the worker, logs the value, and returns.

Save as worker.go:

// worker.go
package main

import (
    "fmt"
    "sync"
)

func handle(ticket int) {
    if ticket <= 0 {
        panic(fmt.Sprintf("ticket %d: id must be positive", ticket))
    }
    fmt.Printf("handled ticket %d\n", ticket)
}

func worker(ticket int, wg *sync.WaitGroup) {
    defer wg.Done()
    defer func() {
        if v := recover(); v != nil {
            fmt.Printf("worker recovered: %v\n", v)
        }
    }()
    handle(ticket)
}

func main() {
    var wg sync.WaitGroup
    for _, id := range []int{41, 0, 42} {
        wg.Add(1)
        go worker(id, &wg)
    }
    wg.Wait()
    fmt.Println("desk still open")
}

Run:

go run worker.go

Possible output (handled lines and the recovered line may interleave):

handled ticket 41
worker recovered: ticket 0: id must be positive
handled ticket 42
desk still open

The process exits 0. Ticket 0 is still a bug in whoever queued it — recovery is not forgiveness. It is isolation. After you recover, increment a metric or log at error level; do not pretend the ticket succeeded.

Case 3: Recover only in a defer

Calling recover() in ordinary line order always returns nil. This program shows the mistake, then the defer.

Save as recover_place.go:

// recover_place.go
package main

import "fmt"

func wrong() {
    defer func() {
        fmt.Println("wrong recovered:", recover())
    }()
    v := recover()
    fmt.Println("inline recover:", v)
    panic("rail jammed")
}

func main() {
    wrong()
    fmt.Println("after wrong")
}

Run:

go run recover_place.go

Output:

inline recover: <nil>
wrong recovered: rail jammed
after wrong

The inline recover did nothing (no panic was in flight). The deferred recover caught rail jammed and main continued. That is why every real recover is a defer.

Case 4: Recover at an HTTP middleware boundary

Save as server_recover.go. Web handlers run concurrently. If one request handler panics due to unexpected input or a nil pointer, the server should log the panic, return an HTTP 500 error to the client, and continue serving other clients normally.

// server_recover.go
package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                http.Error(w, "internal desk error", http.StatusInternalServerError)
                fmt.Printf("recovered request panic: %v\n", err)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func buggyHandler(w http.ResponseWriter, r *http.Request) {
    panic("unexpected nil pointer in order processor")
}

func main() {
    handler := recoveryMiddleware(http.HandlerFunc(buggyHandler))

    req := httptest.NewRequest("GET", "/order", nil)
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)
    fmt.Printf("HTTP status: %d\n", rec.Code)
    fmt.Printf("HTTP body: %s", rec.Body.String())
}

Run:

go run server_recover.go

Output:

recovered request panic: unexpected nil pointer in order processor
HTTP status: 500
HTTP body: internal desk error

The middleware cleanly caught the handler panic, prevented a process crash, and responded with standard HTTP status code 500.

The trap

Trap 1: Using panic as a substitute for error

Using panic as a substitute for error, then recovering in the same function so it “looks like exceptions.”

Save as fake_exceptions.go:

// fake_exceptions.go
package main

import "fmt"

func openTable(n int) (err error) {
    defer func() {
        if v := recover(); v != nil {
            err = fmt.Errorf("%v", v)
        }
    }()
    if n <= 0 {
        panic(fmt.Sprintf("table %d: number must be positive", n))
    }
    fmt.Printf("opened table %d\n", n)
    return nil
}

func main() {
    fmt.Println(openTable(0))
    fmt.Println(openTable(3))
}

Run:

go run fake_exceptions.go

Output:

table 0: number must be positive
opened table 3
<nil>

It works. It is also slower, harder to read, and hostile to errors.Is. Write return fmt.Errorf(...). Save panic for “this cannot happen if the program is correct.”

Trap 2: Panics do not cross goroutine boundaries

A defer recover() in main or in an HTTP handler cannot catch a panic that happens inside a spawned goroutine. Each goroutine maintains its own independent call stack. If a background goroutine panics without its own deferred recover, the entire process crashes immediately.

Save as goroutine_boundary.go:

// goroutine_boundary.go
package main

import (
    "fmt"
    "time"
)

func main() {
    // Deferred recover in main
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered in main:", r)
        }
    }()

    // Spawned background worker with its own recovery boundary
    go func() {
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("recovered inside worker:", r)
            }
        }()
        panic("hardware sensor failed")
    }()

    time.Sleep(50 * time.Millisecond)
    fmt.Println("desk server still running")
}

Run:

go run goroutine_boundary.go

Output:

recovered inside worker: hardware sensor failed
desk server still running

If you remove the defer func() { recover() } from inside the goroutine, recovered in main is never called. The process exits with code 2. Every background goroutine you launch must own its own error or recovery boundary.

The boring rule

  • User input, network, disk, “not found”: error.
  • Impossible state in your code: panic, or a test that fails before production.
  • recover only at a goroutine or process boundary (worker, HTTP server middleware, plugin).
  • Panics do not bubble across goroutines. Every spawned goroutine that could panic must have its own recovery handler.
  • After recover, log the value. Do not continue as if the work succeeded.
  • recover() belongs in a defer func. Anywhere else it is a no-op.
  • Do not build an exception system out of panic.

Try this

  1. In invariant.go, stop calling mustLead(nil). The program should print one line and exit 0.
  2. In worker.go, panic on ticket 42 as well. Confirm you still see desk still open and two recovered lines.
  3. In goroutine_boundary.go, comment out the worker’s inner defer block and run again. Notice that recovered in main does not catch it and the program crashes.
  4. Replace panic in fake_exceptions.go with return fmt.Errorf(...) and delete the defer. Behavior of main stays the same; the function gets honest.