skip to content

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

level: middleimportance: should knowfreq 44%

answer

  1. one word in the rule decides it
  2. which function was actually deferred?
  3. a helper is one frame too deep
  4. the value must be passed, not fetched

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.

solid answer

~50 s

The rule is stricter than "somewhere under a defer": `recover()` must be called **directly** by a function that was deferred by the panicking goroutine. In `defer func() { handle() }()`, the deferred function is the closure; `handle` is an ordinary call from inside it, so a `recover()` in `handle` sees no panic in flight for its own frame and returns nil. Nothing warns you — the code compiles, the helper runs, and the panic continues to unwind and kill the process. Note that `defer handle()` is different and does work: there `handle` itself is the deferred function, so its own `recover()` call is direct. The fix is to call `recover()` in the deferred function itself and pass the value down: a shared handler should take the recovered value as a parameter, never try to fetch it itself.

code

go · 12 lines
go
func run() {
	defer func() {
		handle() // ordinary call: its recover() returns nil
	}()
	panic("boom")
}

func handle() {
	if r := recover(); r != nil {
		fmt.Println("never printed:", r)
	}
}

go deeper

for a junior

Learn the shape rather than the theory: the recover() call goes in the body of the function you deferred. If you find yourself calling a helper to do the recovering, the panic will not stop.

for a middle

State the rule with the word directly in it, and show both the broken and the fixed form. Explain that the fix is to recover at the deferred site and pass the value into the shared handler.

for a senior

Explain why the runtime enforces it — it compares the caller's frame with the deferred call being run — and why an ambient recover would be dangerous. Mention how you would test that a recovery boundary actually holds.

for a principal

Own the review rule: a recovery boundary is code that must be proven by a test that really panics, because this failure is invisible on inspection and only shows up as an unexplained process death in production.

## The rule people paraphrase wrongly Most descriptions of `recover()` say "call it in a deferred function". The actual rule adds one word that decides whether your code works: > `recover()` returns the panic value only when it is called **directly** by a function that was deferred by a function in the panicking goroutine. "Directly" means the call to `recover()` appears in the body of the deferred function itself — not in something that function calls. ## The two shapes that look identical ```go // Works: handle is the deferred function, so its recover() call is direct. defer handle() // Does not work: the closure is the deferred function; handle is an ordinary call. defer func() { handle() }() ``` The second is what people write when they want to reuse one recovery routine in several places, and it silently does nothing. `handle`'s `recover()` returns nil, the `if r != nil` branch is skipped, `handle` returns, the closure returns, and the panic resumes unwinding as though no handler existed. There is no compile error and no runtime complaint. The first shape is not a workaround to reach for casually — it works, but it also means the handler cannot take the recovered value as an argument, which is usually what you want. ## Why the rule exists The runtime has to decide whether a given `recover()` call is entitled to stop the panic. It does that by comparing the frame that called `recover()` with the frame of the deferred call it is currently running as part of unwinding. If they are the same, the panic belongs to this handler and the value is returned. If `recover()` was reached through an extra call, the frames differ and the runtime returns nil. Without that rule, any function anywhere in the program could swallow a panic that was passing overhead — including library code you called from inside a handler. The restriction makes recovery something a frame opts into explicitly and locally, which is exactly the property that keeps `recover()` from becoming an ambient catch-all. ## The other ways recover() returns nil It helps to hold all of them together, because the symptom is the same silent nil in each case: - The goroutine is not panicking. This is the ordinary case, and it is why every handler tests the result. - `recover()` was not called directly by a deferred function — the case above. - A panic is in flight but was already recovered by an inner deferred call, so there is nothing left to recover. Historically there was a fourth: `panic(nil)` produced a nil value, so a real panic was indistinguishable from no panic at all. Since Go 1.21 that call raises a `*runtime.PanicNilError` instead, so a non-nil result now genuinely means a panic happened. The old behaviour is available with `GODEBUG=panicnil=1` for code that depended on it. ## Writing a reusable handler correctly Keep the `recover()` call at the deferred site and make the shared routine take the value: ```go defer func() { if r := recover(); r != nil { handle(r) } }() ``` `handle(r any)` can then do the formatting, classification and reporting, and it can be shared by as many call sites as you like. The two-line closure is the price of the rule, and it is worth paying because it puts the recovery decision visibly at the frame that owns it. ## How this bug is usually found It is found in production, because the code looks correct on review and the test that would have caught it — one that actually panics and asserts the caller survives — often does not exist. The cheap check is a single test that panics inside the guarded region and asserts the function returns instead of taking the process down. If the helper-call mistake is present, that test crashes the whole test binary, which is a loud and unambiguous signal. ## The takeaway One stack frame of indirection is the difference between a recovery boundary and decorative code. Put `recover()` in the deferred function; pass its result to whatever does the work.

  • Does defer handle() work, where handle is a named function that calls recover() itself?
    Yes. There `handle` is the deferred function, so its own `recover()` call is direct and returns the panic value. The catch is that this shape cannot pass anything in, so a handler shared across call sites is usually written as a closure that recovers and forwards the value instead.
  • List the situations in which recover() returns nil.
    The goroutine is not panicking; `recover()` was not called directly by a deferred function; or the panic was already recovered by an inner deferred call. Before Go 1.21 there was a fourth — `panic(nil)` — but that now raises a `*runtime.PanicNilError`, so a non-nil result means a real panic.
  • How would you write a test that catches this mistake?
    Call the guarded function with input that makes it panic and assert that it returns. If the `recover()` is one frame too deep the panic escapes and takes the whole test binary down, which is impossible to miss. Without such a test the bug is invisible on review.

saying these in an interview costs you the question

  • Says recover works anywhere under a deferred call
  • Writes a shared handler that calls recover itself
  • Expects a compile error or vet warning for the misplaced call
  • Confuses defer handle() with defer func(){ handle() }()
  • Assumes a nil result means no panic occurred