Skip to content

recover works only in the deferred call

Level: 301 · anyone whose recover returned nil while the program was plainly panicking

One line: recover stops a panic only when called directly by the deferred function; a helper the deferred function calls gets nil, defer recover() itself stops nothing, and outside a panic there is nothing to recover — while inside one, since Go 1.21, the recovered value is never nil, even for panic(nil).

caught runs a job under its own deferred recover and reports what escaped from the job. The six cases: recover outside any panic; a deferred function that calls recover itself; a deferred function that asks a helper to; defer recover(); panic(nil); and an index out of range.

Verified output of recover_only_in_the_deferred_call_itself_go.go — regenerated by tools/run_examples.py, never hand-typed.

1. recover outside any panic: <nil>
2. defer recoverInTheDeferredFunction():
   the deferred function recovered: invoice 18 has no customer
   the caller saw: no panic
3. defer recoverInAHelper():
   a helper called by the deferred function got: <nil>
   the caller saw: invoice 19 has no customer
4. defer recover():
   the caller saw: invoice 20 has no customer
5. panic(nil):
   *runtime.PanicNilError: panic called with nil argument
6. a runtime error:
   runtime error: index out of range [3] with length 3
   is an error: true, errors.As runtime.Error: true

Reading the output

1. Outside any panic, nil. The spec ↗ lists when recover returns nil: the goroutine is not panicking, or recover was not called directly by a deferred function. Case 1 is the first: a recover at the top of a function, not deferred, never does anything.

2. The deferred function itself: recovered, and the caller saw no panic. recoverInTheDeferredFunction is the deferred function and it calls recover — called directly by a deferred function, exactly the spec's condition. The panic stopped there; the job returned normally; caught reported no panic.

3. A helper called by the deferred function: nil, and the panic went on. recoverInAHelper is the deferred function, but recover was called one frame deeper, in helperRecover. That is not directly, so it returned nil and the panic continued up to caught's own deferred closure. A recover wrapped in a utility — handlePanic(), logAndRecover() — is this case, and it is silent: the helper compiles, runs, and does nothing.

4. defer recover(): the panic went on. Here recover is the deferred function; it is not called by one. It ran, returned nil, and the panic continued. defer recover() compiles and go vet says nothing — the vet page's What no tool catches.

5. panic(nil) recovered as a *runtime.PanicNilError. Before Go 1.21, recover returned nil for it, indistinguishable from no panic. The Go 1.21 release notes ↗ define that when a goroutine is panicking and recover was called directly by a deferred function, its result is guaranteed not to be nil; panic(nil) now raises a run-time panic of type *runtime.PanicNilError ↗. A module whose go line says 1.20 or earlier gets the old behaviour through GODEBUG=panicnil=1.

6. An index out of range recovered as a value that is an error and a runtime.Error. The spec's Run-time panics ↗: the value is of an implementation-defined type that satisfies runtime.Error ↗. errors.As with a runtime.Error target is how a recovering function tells a bug in the code (this) from a value some code panicked with on purpose — errors.As finds a type in the chain.

Recovering does not have to mean surviving

Verified output of recover_only_in_the_deferred_call_repanic_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build same_value.go
$ ./same_value
recovered: invoice 18 has no customer -- cannot handle it here, panicking again
panic: invoice 18 has no customer [recovered, repanicked]
exit status 2
$ go build new_value.go
$ ./new_value
panic: invoice 18 has no customer [recovered]
    panic: posting invoices: invoice 18 has no customer
exit status 2

panic(r) with the recovered value: exit status 2, one line. A deferred function may look at the value, decide it cannot handle it, and panic again; the program still fails, with the original value. Go 1.25 prints that case as [recovered, repanicked] on one line — the release notes ↗ say the message no longer repeats the text of the panic value; earlier versions printed [recovered] and then the value again on a second line.

A new value: [recovered], then the new panic under it. The first panic is shown as recovered and the second as the one in progress. Either way the exit status is 2, and any deferred calls between the two ran.

And the case this page does not repeat: a recover in main cannot save the program from a panic in another goroutine, because recover acts on the goroutine that is panicking and no other — A panic ends the whole program shows it with exit status 2, and shows the fix, a deferred recover inside the goroutine.

recover_only_in_the_deferred_call_itself_go.go and recover_only_in_the_deferred_call_repanic_sh.sh

recover_only_in_the_deferred_call_itself_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// recover stops a panic only when the deferred function itself calls it. A
// helper that the deferred function calls gets nil; so does `defer recover()`
// (there recover is the deferred function, not called by one); so does a
// recover outside any panic. Since Go 1.21 a panic(nil) recovers as a
// *runtime.PanicNilError, so during a panic the recovered value is never nil;
// a runtime error recovers as a value that implements runtime.Error.
//
//  go build recover_only_in_the_deferred_call_itself_go.go && ./recover_only_in_the_deferred_call_itself_go
package main

import (
    "errors"
    "fmt"
    "runtime"
)

// caught runs job and returns what its own deferred closure recovered, or
// "no panic" if the job returned normally.
func caught(job func()) (value any) {
    defer func() {
        if r := recover(); r != nil {
            value = r
        }
    }()
    job()
    return "no panic"
}

func recoverInTheDeferredFunction() {
    fmt.Println("   the deferred function recovered:", recover())
}

func recoverInAHelper() {
    fmt.Println("   a helper called by the deferred function got:", helperRecover())
}

func helperRecover() any { return recover() }

func main() {
    fmt.Println("1. recover outside any panic:", recover())

    fmt.Println("2. defer recoverInTheDeferredFunction():")
    fmt.Println("   the caller saw:", caught(func() {
        defer recoverInTheDeferredFunction()
        panic("invoice 18 has no customer")
    }))

    fmt.Println("3. defer recoverInAHelper():")
    fmt.Println("   the caller saw:", caught(func() {
        defer recoverInAHelper()
        panic("invoice 19 has no customer")
    }))

    fmt.Println("4. defer recover():")
    fmt.Println("   the caller saw:", caught(func() {
        defer recover()
        panic("invoice 20 has no customer")
    }))

    fmt.Println("5. panic(nil):")
    v := caught(func() { panic(nil) })
    fmt.Printf("   %T: %v\n", v, v)

    fmt.Println("6. a runtime error:")
    v = caught(func() {
        prices := []int{1999, 450, 1200}
        i := 3
        fmt.Println(prices[i])
    })
    err, isError := v.(error)
    var re runtime.Error
    fmt.Printf("   %v\n   is an error: %t, errors.As runtime.Error: %t\n", v, isError, errors.As(err, &re))
}

recover_only_in_the_deferred_call_repanic_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# Recovering a panic and panicking again keeps the program's failure: exit
# status 2 either way. Since Go 1.25 a re-panic with the same value prints one
# line, "[recovered, repanicked]"; a panic with a new value prints the first
# panic marked "[recovered]" and the new one under it. The goroutine traces
# carry addresses, so only the panic lines are kept.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/same_value.go" <<'GO'
package main

import "fmt"

func main() {
    defer func() {
        r := recover()
        fmt.Println("recovered:", r, "-- cannot handle it here, panicking again")
        panic(r)
    }()
    panic("invoice 18 has no customer")
}
GO

cat >"$dir/new_value.go" <<'GO'
package main

import "fmt"

func main() {
    defer func() {
        r := recover()
        panic(fmt.Errorf("posting invoices: %v", r))
    }()
    panic("invoice 18 has no customer")
}
GO

say() { printf '$ %s\n' "$*"; }
cd "$dir" || exit 1

for program in same_value new_value; do
    say "go build $program.go"
    go build "$program.go" || exit 1
    say "./$program"
    "./$program" 2>stderr.txt
    status=$?
    grep 'panic:' stderr.txt
    echo "exit status $status"
done

What to do

  • Call recover in the deferred function literal itself: defer func() { if r := recover(); r != nil { ... } }(). Not in a helper it calls, and not as defer recover().
  • Recover in the goroutine that can panic, at the top of its function, before anything that might. Every wg.Go body and every go func that must not take the program down gets its own.
  • Check the value with errors.As — against runtime.Error for bugs, against your own error types for deliberate panics — and turn either into an error the caller can handle, through a named result (A defer can change a named result).
  • Panic again when you cannot handle it. A recover that swallows a value it does not understand turns a loud bug into a silent one; panic(r) keeps the failure and, since Go 1.25, the one-line report.

Practice

Write safely(name string, job func()) (err error) that runs the job under a deferred recover — in the deferred function itself — and returns nil when the job returns normally, <name>: bug: <err> wrapping a runtime.Error, <name>: <err> wrapping an error value the job panicked with, and <name>: panic: <value> for anything else. Run it over four jobs and show errors.As and errors.Is finding what they should.

Solution

recover_only_in_the_deferred_call_itself_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: a `safely` wrapper that turns a panic in a job into an error, in the
// deferred function itself, and tells a runtime error (a bug) from an error
// value the job panicked with, and from anything else.
package main

import (
    "errors"
    "fmt"
    "runtime"
)

var errNoCustomer = errors.New("no customer on the order")

func safely(name string, job func()) (err error) {
    defer func() {
        switch v := recover().(type) {
        case nil:
            // the job returned normally
        case runtime.Error:
            err = fmt.Errorf("%s: bug: %w", name, v)
        case error:
            err = fmt.Errorf("%s: %w", name, v)
        default:
            err = fmt.Errorf("%s: panic: %v", name, v)
        }
    }()
    job()
    return nil
}

func main() {
    jobs := []struct {
        name string
        job  func()
    }{
        {"ship", func() { fmt.Println("   shipped order 18") }},
        {"index", func() { var lines []string; fmt.Println(lines[2]) }},
        {"charge", func() { panic(errNoCustomer) }},
        {"print", func() { panic("printer offline") }},
    }
    for _, j := range jobs {
        err := safely(j.name, j.job)
        var re runtime.Error
        fmt.Printf("%-7s err = %v | runtime.Error: %t | errNoCustomer: %t\n",
            j.name+":", err, errors.As(err, &re), errors.Is(err, errNoCustomer))
    }
}

Verified output of recover_only_in_the_deferred_call_itself_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

   shipped order 18
ship:   err = <nil> | runtime.Error: false | errNoCustomer: false
index:  err = index: bug: runtime error: index out of range [2] with length 0 | runtime.Error: true | errNoCustomer: false
charge: err = charge: no customer on the order | runtime.Error: false | errNoCustomer: true
print:  err = print: panic: printer offline | runtime.Error: false | errNoCustomer: false

In other languages

  • Rust unwinds on a panic and runs Drop on the way, and catch_unwind ↗ is the nearest thing to recover — meant for FFI boundaries and test harnesses, not control flow, as the Rust library's What a panic costs ↗ argues; with panic = "abort" there is nothing to catch.
  • Python and C++ catch exceptions from any frame up the call stack, with except and catch (Errors and Exceptions ↗, try block ↗); Go's recover is deliberately narrower — one deferred function, one goroutine.
  • The Concurrency library's concept page Goroutine ↗ collects what ends a goroutine, this library's panic lesson among its links.

Sources

  • The Go specification: Handling panics ↗ — the two conditions under which recover returns nil — and Run-time panics ↗.
  • recover ↗ and panic ↗ in package builtin; runtime.Error ↗ and runtime.PanicNilError ↗.
  • Go 1.21 release notes, Changes to the language ↗ — panic(nil) and GODEBUG=panicnil=1.
  • Go 1.25 release notes, Runtime ↗ — the [recovered, repanicked] message.
  • Defer, Panic, and Recover ↗ — the recover example, and the note that recover is only useful inside deferred functions.
  • Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 5, "Functions" — section 5.10, "Recover", on recovering selectively.
  • Adam Freeman, Pro Go (Apress, 2022), chapter 15, "Error Handling" — "Recovering from Panics", "Panicking After a Recovery" and "Recovering from Panics in Go Routines".
  • Matt Butcher, Matt Farina and Nathan Kozyra, Go in Practice, 2nd ed. (Manning, 2025), chapter 4, "Handling errors and panics" — section 4.3, "The panic system".
  • Miki Tebeka, Effective Go Recipes (Pragmatic Bookshelf, 2024), chapter 8, "Working with Errors" — Recipe 43, "Handling Panics", and Recipe 44, "Handling Panics in Goroutines".