skip to content

Why can't a deferred recover in a parent function catch a panic in a goroutine it started?

level: middleimportance: must knowfreq 62%

answer

  1. go starts, it does not call
  2. separate stacks, separate defer chains
  3. recover asks about the running goroutine
  4. returns nil when this goroutine is fine
  5. wrap the body, not the go statement

basics

~20 s

Each goroutine has its own stack and defer chain, and a panic unwinds only that one. A recover sees a panic only on the goroutine running it, so the parent's recover returns nil while the process dies.

solid answer

~40 s

`go f()` is not a call: it starts a fresh goroutine with its own stack, and the starting function keeps running independently. A panic unwinds only the stack of the goroutine that panicked, executing that goroutine's deferred functions and no others, so a deferred `recover` in the parent is never reached by the unwinding. Even when the parent is still executing and its defer eventually runs, `recover()` consults the *currently running* goroutine's panic state and returns `nil` because that goroutine is not panicking. In practice the parent never gets that far: the child's unrecovered panic terminates the whole process first. The only place recovery can happen is a function deferred by the panicking goroutine itself, which is why containment has to be written into the body each goroutine runs.

code

go · 14 lines
go
func startWorker() {
	defer func() {
		// dead code: this goroutine is not the one that panics
		if r := recover(); r != nil {
			fmt.Println("recovered:", r)
		}
	}()
	go work()
	time.Sleep(time.Second)
}

func work() {
	panic("from the child goroutine")
}

go deeper

for a junior

Remember the rule in one line: a recover only works on the goroutine that panicked, so a guard placed around the go statement does nothing.

for a middle

Explain the mechanism: go creates a separate stack with its own defer chain, unwinding never crosses goroutines, and recover reports the panic state of whichever goroutine is executing it.

for a senior

Point out the disguised versions in real code — a recover in main, a recovering helper called from the wrong goroutine, request middleware that cannot cover a goroutine the handler starts — and say where the wrapper actually belongs.

for a principal

Own the review rule that follows: every go statement launching code the team does not control should launch a wrapper, and a language with no cross-goroutine catch makes that a convention people must be taught rather than a default they inherit.

## Two facts that together explain everything **1. `go` starts something, it does not call something.** When you write `go work()`, the arguments are evaluated immediately, but `work` then runs on a brand-new goroutine with its own stack. The function that executed the `go` statement continues at the next line. There is no caller/callee relationship between the two goroutines at runtime: the new goroutine's stack does not sit on top of the starter's stack, it is a separate stack entirely. **2. A panic is a per-goroutine unwinding.** `panic(v)` walks back up the stack of the goroutine that called it, running the deferred functions registered by *that* goroutine's frames. It never crosses into another goroutine's frames, because it has no path to them. Put those together and the answer is structural: the parent's deferred functions are not on the panicking goroutine's stack, so the unwinding cannot reach them. ## What `recover()` actually looks at `recover()` is not a global switch. It inspects the panic state of the goroutine that is executing it right now, and it only returns a non-nil value when three things hold at once: that goroutine is currently panicking, the call is made directly by a function that goroutine deferred, and the panic has not already been recovered. Call it on a goroutine that is not panicking — which is exactly the parent's situation — and it returns `nil`. So even in the artificial case where the parent's defer does run (say the child panics a moment later), the parent's `recover()` returns `nil` and the parent proceeds as if nothing happened. It has no channel through which the child's panic could be delivered to it. In a real program you rarely observe even that, because the child's unrecovered panic terminates the process while the parent is still running. The parent's deferred function never executes at all. ## The shape people write by mistake ```go func startWorker() { defer func() { if r := recover(); r != nil { // never sees the child's panic log.Println("recovered:", r) } }() go work() // work panics time.Sleep(time.Second) } ``` This reads like a try/catch around the goroutine, and it is nothing of the sort. Engineers arriving from languages where a thread's exception is caught by a pool, an executor, or a supervising task expect a guard like this to hold. In Go it is dead code. The same mistake appears in slightly better disguise: - a `recover` in `main`, on the theory that `main` is the root of everything — `main` is just another goroutine; - a "safe" helper package whose exported function defers a recover and is then called *from the parent* rather than from inside the goroutine; - middleware or a framework hook that recovers on the request-handling goroutine, which does nothing for a goroutine that handler starts. ## Where the recover has to be The deferred function calling `recover` must be deferred **by the goroutine that will panic**, meaning it must be registered from within the function chain that the `go` statement launched. Concretely, that means either the function you pass to `go` starts with the deferred recover, or you wrap it: ```go go func() { defer func() { if r := recover(); r != nil { // this one runs: same goroutine } }() work() }() ``` There is no way to install this from the outside. Go gives you no API to attach a handler to a goroutine you started, no goroutine identifier to attach it to, and no hook the runtime calls before it decides a panic is fatal. ## Why the language offers no cross-goroutine catch Giving the starter a way to catch a child's panic would imply a parent/child relationship the runtime does not maintain. Goroutines have no parentage: the starter may return, or itself finish, long before the goroutine it started. The panic value would have nowhere to be delivered and no frame to resume in. Instead, Go's answer is explicit communication — the goroutine converts its own failure into a value and sends it back over a channel, exactly as it would send a successful result. ## The consequence for design Because containment cannot be installed from outside, it is a property of the goroutine body, and therefore of whoever writes the `go` statement. If a function will be run on its own goroutine and might panic — especially if it is code registered by another package — the person launching it owns wrapping it. A code reviewer's version of this rule is short: any `go` statement whose function you do not fully trust should be launching a wrapper, not the untrusted function itself.

  • What does recover() return when it runs in a deferred function on a goroutine that is not panicking?
    It returns `nil`. `recover` reports the panic state of the goroutine currently executing it, so on a healthy goroutine it is simply a no-op that yields nil. That is why the guard `if r := recover(); r != nil` is the idiomatic shape — the deferred function runs on every normal return too, and must do nothing in that case.
  • Is there any way to attach a panic handler to a goroutine from outside, after starting it?
    No. Go exposes no goroutine handle, no identifier, and no runtime hook for installing a handler on someone else's goroutine. The only lever is the function you hand to `go`, so containment is decided at the moment you write the `go` statement — usually by launching a wrapper that defers its own recover around the real work.
  • If the parent cannot catch it, how does a goroutine's failure reach the code that started it?
    The goroutine turns the failure into an ordinary value and sends it back — typically an error on a results channel, or a slot in a struct guarded by a `sync.WaitGroup` the starter waits on. A deferred recover inside the goroutine converts a panic into that same kind of value, so the starter sees one uniform failure path.

Each goroutine carries its own pile of cleanup slips. Unwinding reads the pile of the goroutine that fell over, and recover can only read the pile it is standing on.

saying these in an interview costs you the question

  • Treats defer plus recover in the parent as try/catch around go
  • Thinks a recover in main covers every goroutine
  • Believes goroutines have a parent that receives their panic
  • Expects recover to work when called from a helper on another goroutine
  • Assumes the runtime offers a hook to handle goroutine panics