skip to content

Unwinding and recover

A panic runs deferred functions up the stack until one of them calls recover, which returns the panic value and stops the unwind; anywhere else recover just returns nil.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

What does Go's built-in recover() do, and where must it be called to stop a panic?

level: juniorimportance: must knowfreq 80%

answer

  1. not a catch block
  2. only from a deferred call
  3. returns whatever panic was given
  4. nil when nothing is panicking
  5. the recovering function just returns

basics

~10 s

recover() stops a panicking goroutine from dying and returns the value that was passed to panic. It works only when called directly inside a function that was deferred; called anywhere else it returns nil.

solid answer

~50 s

A `panic` stops the normal flow and starts unwinding the goroutine's stack, running every deferred call it passes on the way out. `recover()` is the only way to interrupt that: called directly inside one of those deferred functions, it stops the unwinding and returns the value the `panic` was given, typed as `any`. Called anywhere else — in ordinary code, or when nothing is panicking — it just returns `nil`, which is why the idiom is always `defer func() { if r := recover(); r != nil { ... } }()`. It is not a catch block: execution does not resume where the panic happened. The function whose deferred call recovered simply returns to its caller, and every frame below it is already gone. If no deferred call recovers, the runtime prints the panic value and the goroutine's stack and terminates the whole program.

code

go · 8 lines
go
func run() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered:", r) // prints: recovered: boom
		}
	}()
	panic("boom")
}

go deeper

for a junior

Be ready to write the four-line idiom from memory and say out loud that recover only works inside a deferred function and returns nil otherwise. Knowing that an unrecovered panic ends the whole program, not just one function, is expected.

for a middle

Explain the mechanics: panic unwinds the stack running deferred calls, recover stops that unwinding, and the recovering function then returns normally to its caller rather than resuming where the panic happened.

for a senior

Show judgment about the value that comes back: it is an any of unknown dynamic type, runtime-raised panics arrive as runtime.Error, and code that reports it must handle both. Be able to say what recovering does not restore.

for a principal

Own the position that panic is not the failure channel. Argue why a codebase keeps recover rare and deliberate, and what it means for a team when recovery starts appearing as routine error handling instead of a boundary.

## The two halves of the mechanism Go has no exceptions. It has one abrupt-termination mechanism, built from three built-in identifiers that only make sense together: `panic`, `defer` and `recover`. `panic(v)` takes any value and stops the normal execution of the current function immediately. Statements after the panic point never run. The runtime then *unwinds* the goroutine's stack: it walks outward frame by frame, and in each frame it runs the calls that function had deferred. The panicking function's own deferred calls run first, then its caller's, then its caller's caller's, and so on. `recover()` is the brake. It is a built-in that returns `any`. Its contract has exactly one useful case and several nil cases. ## The rule, stated precisely `recover()` returns the value passed to `panic` **only when it is called directly by a function that was deferred by a function in the panicking goroutine, while that goroutine is panicking.** In every other situation it returns `nil`: - the goroutine is not panicking at all; - `recover()` was not called directly by a deferred function (for example, a helper that a deferred function calls); - nothing has panicked yet in this frame's lifetime. That is why you almost never see a bare `recover()`. The canonical shape is a deferred closure that both calls it and tests the result: ```go defer func() { if r := recover(); r != nil { // r is the value passed to panic } }() ``` The `r != nil` test is not decoration. Because `recover()` returns `nil` when nothing is panicking, a deferred function containing that call runs on *every* return from the enclosing function, panic or not, and the test is how it tells the two apart. ## What you get back The value is whatever the `panic` call was given, boxed in an `any`. If your own code wrote `panic("config missing")` you get a `string`. If your code wrote `panic(err)` you get that error value. If the runtime raised the panic — a nil pointer dereference, an index out of range, a write to a nil map, a failed type assertion — the value implements the `runtime.Error` interface, which also satisfies `error`, and prints as something like `runtime error: invalid memory address or nil pointer dereference`. Code that inspects a recovered value therefore has to handle an arbitrary dynamic type, usually with a type switch or by formatting it with `%v`. ## What recovering actually changes Recovering stops the unwinding at the frame whose deferred call made the call. Execution does **not** resume at the panic site, and it does not resume in the middle of any frame that was already unwound — those frames are gone, and only their deferred calls ran. What happens instead is that the function whose deferred call recovered finishes as if it had returned normally at that moment, handing control back to *its* caller. Its results are whatever its result variables hold at that point. So recovery converts "this goroutine dies" into "this one function returns early", and nothing more. It restores no state, undoes no partial work, and re-runs nothing. ## If nobody recovers The unwinding reaches the top of the goroutine's stack, and the runtime prints the panic value followed by a stack trace and terminates the process. This is the default and it is the right default: an unrecovered panic means the program reached a state its author did not believe was reachable, and continuing from there is usually worse than stopping. ## Why it is deliberately awkward The shape of the API — a built-in that only works from inside a deferred call, returning an untyped value — makes `panic`/`recover` unpleasant to use as general-purpose control flow, and that is intentional. Go's normal failure channel is a returned `error` value. `recover()` exists so that a program can put a deliberate lid on a region — a request, a plugin call, one case in a test harness — without every ordinary function pretending to be exception-safe. ## The shape to have in your head A deferred closure, a `recover()` call directly inside it, a nil test, and a decision about what to do with the value. Anything more indirect than that silently does not work.

  • What does recover() return when the goroutine is not panicking?
    `nil`. That is why the idiom always tests the result: the deferred function containing the call runs on every return, panic or not, and the nil test is how it distinguishes them. Since Go 1.21, even `panic(nil)` no longer produces a nil result — it raises a `*runtime.PanicNilError`, so a non-nil result really does mean a panic happened.
  • After a recover, does execution resume at the statement that panicked?
    No. That frame and every frame between it and the recovery point are already unwound; only their deferred calls ran. The function whose deferred call recovered returns to its own caller as if it had returned normally at that point. Nothing is retried and nothing resumes mid-frame.
  • Can you recover from a panic raised by the runtime, such as an index out of range?
    Yes — runtime-raised panics unwind exactly like `panic()` calls, and the recovered value implements the `runtime.Error` interface, which also satisfies `error`. A separate class of runtime aborts, such as concurrent map writes or stack exhaustion, is fatal and cannot be recovered at all.

A panic is a building evacuation, not an exception: every floor's shutdown duties (its deferred calls) still get done on the way out. recover() is one floor deciding the evacuation stops there — the floors below have already emptied and nobody goes back to their desk.

saying these in an interview costs you the question

  • Describes recover as try/catch that resumes at the panic point
  • Says recover works anywhere inside the panicking function
  • Puts recover at the top of a function to guard later code
  • Thinks an unrecovered panic only kills the one goroutine
  • Forgets the nil test and treats every deferred run as a panic
open as a page

A deferred closure calls a helper, and the helper calls recover() — why does the panic keep unwinding?

level: middleimportance: should knowfreq 44%

basics

~20 s

recover() returns the panic value only when it is called directly by a deferred function. A helper invoked from that deferred function is one level too deep, so its recover() returns nil and the panic carries on unwinding.

open as a page

When a deferred function in an outer frame recovers a panic raised three calls deeper, which deferred calls have already run and where does execution continue?

level: middleimportance: should knowfreq 58%

basics

~20 s

Every deferred call in every frame between the panic and the recovery point has already run, innermost frame first. Once one of them recovers, unwinding stops and that function returns normally to its caller; the frames below it are gone.

open as a page

A Go test harness defers a recover around each case so one panic cannot kill the run — what does recovering fail to undo?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Recovering stops the unwinding and undoes nothing else. Statements the panicking code never reached still have not run, so anything left half-done stays half-done unless a deferred call cleaned it up, and shared state carries the damage into later cases.

open as a page