skip to content

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%

answer

  1. the stack does not stay put
  2. defers fire on the way out
  3. deepest frame's cleanup goes first
  4. no jump back to the panic site
  5. the recovering function just returns

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.

solid answer

~50 s

The panic unwinds outward. In the frame that panicked, its deferred calls run in last-in-first-out order; that frame is then abandoned and its caller's deferred calls run, and so on up the chain. The first deferred call that invokes `recover()` and gets a non-nil value stops the unwinding right there. Execution does not resume at the panic site or anywhere in the abandoned frames — the function that owns the recovering `defer` simply finishes, returning to its own caller as if it had returned at that point, with whatever its result variables currently hold. Everything after the panicking call in each unwound function is skipped: only deferred work runs. If two frames both defer a recovering closure, the inner one wins, and the outer one's `recover()` then returns nil because there is no longer a panic in flight.

code

go · 23 lines
go
type config struct{ name string }

func a() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("a recovered:", r)
		}
	}()
	b()
	fmt.Println("a: not reached once b panics")
}

func b() {
	defer fmt.Println("b: defer ran")
	c()
	fmt.Println("b: not reached")
}

func c() {
	defer fmt.Println("c: defer ran")
	var cfg *config
	fmt.Println(cfg.name) // nil pointer dereference: panics here
}

go deeper

for a junior

Remember two facts: deferred calls still run while a panic unwinds, and everything else in those functions is skipped. That is why cleanup belongs in a defer rather than at the end of the body.

for a middle

Trace the order out loud — deepest frame's defers first, then each caller's, until one recovers — and be precise that the recovering function returns to its caller rather than resuming anywhere.

for a senior

Reason about what the surviving caller receives: zero-valued results unless the results are named, a program whose intermediate frames did only their deferred work, and any non-deferred cleanup left undone.

for a principal

Frame the guarantee for the codebase: unwinding runs deferred work and nothing else, so a team's cleanup discipline is what decides whether a recovered panic leaves a usable process behind.

## The walk outward Suppose `a()` calls `b()`, `b()` calls `c()`, and something in `c()` dereferences a nil pointer. The runtime raises a panic in `c`'s frame and begins unwinding the goroutine's stack. The order of events is fixed: 1. The rest of `c`'s body is abandoned. No further statement in `c` executes. 2. `c`'s deferred calls run, most recently deferred first. 3. `c`'s frame is popped. Control does **not** return to the statement after `c()` in `b`. 4. The rest of `b`'s body is abandoned; `b`'s deferred calls run, most recently deferred first; `b`'s frame is popped. 5. The same happens in `a`. 6. If no deferred call along the way recovered, the runtime prints the panic value and a stack trace and ends the process. The useful summary is: **a panic runs deferred work and nothing else.** Any statement that was going to run after the call that panicked — a `Close`, a metric increment, an unlock written as a plain statement rather than a `defer` — does not run. ## Where recovery cuts in At step 2, 4 or 5, a deferred function may call `recover()` directly. The first such call to see a non-nil value ends the panic. From that instant the goroutine is no longer panicking. What happens next is the part people get wrong. Execution does not jump back to the panic site, and it does not continue in the middle of the function that recovered. The frames below the recovery point were already popped; there is nothing to go back to. Instead the function whose deferred call recovered completes: its remaining deferred calls (if any) still run, and then it returns to *its* caller exactly as a normal return would. If that function has ordinary (unnamed) results, the caller receives their zero values, because no `return` statement ever assigned them. If it has named results, the caller receives whatever those variables hold at that moment — which is what makes it possible for a deferred function to set them before the return completes. ## Nested handlers When more than one frame in the chain defers a recovering closure, the innermost one reached during unwinding wins, because unwinding visits frames from the inside out. Once it recovers, the panic is over, so the outer closure still runs on the normal return path, but its `recover()` returns nil — which is precisely why the nil test in the idiom matters. ## Panicking during a panic A deferred call that runs during unwinding may itself panic. The new panic takes over and unwinding continues from there; the earlier one is not lost, and if the program dies the runtime's report lists both, oldest first. A `recover()` further out returns the value of the most recent panic. ## Reading it off real output The cheapest way to internalise the order is a three-deep chain where each level prints from a deferred call. The deepest frame's message appears first, then its caller's, then the recovery message — and the statement that followed the panicking call in each frame never prints. That single run answers "do my defers still fire?" (yes, in every unwound frame), "do my normal statements?" (no), and "where do I end up?" (in the caller of whichever function recovered). ## Why the design is this way Deferred calls are the only thing Go guarantees during unwinding, and that guarantee is what makes `defer` the correct place for cleanup rather than the end of the function body. A resource released by a deferred call is released whether the function returns normally, returns early, or is unwound by a panic three frames deeper. A resource released by an ordinary statement at the bottom of the function is released only on the paths that reach it — and a panic is not one of them. ## The mental model to keep Unwinding is one-way. It empties frames as it goes, running their deferred work; recovery only decides where it stops. Nothing is rewound, retried, or resumed.

  • If two frames in the chain both defer a closure that calls recover(), which one gets the panic value?
    The innermost one, because unwinding visits frames from the inside out. It stops the panic there, so the outer closure still runs on the normal return path but its `recover()` returns nil. That is exactly the case the `r != nil` test exists to handle.
  • What happens if a deferred function panics while a panic is already unwinding?
    The new panic takes over and unwinding continues from that point. The earlier panic is not discarded — if the program dies, the runtime's report lists them, oldest first. A `recover()` further out returns the value of the most recent panic, not the original one.
  • Why is a mutex unlocked with defer safe across a panic when an unlock at the end of the function body is not?
    Unwinding runs deferred calls in every frame it passes and skips everything else. A `defer mu.Unlock()` therefore runs on the panic path; a plain `mu.Unlock()` at the bottom of the body is simply never reached, and the mutex stays locked for whatever code recovers and continues.

saying these in an interview costs you the question

  • Says execution resumes after the statement that panicked
  • Thinks only the panicking function's defers run
  • Expects the abandoned frames to finish their remaining statements
  • Believes the outermost recover wins over an inner one
  • Assumes a panic skips deferred calls entirely