Panic Propagation and Stack Unwinding
When a panic fires, the Go runtime does not simply jump to a handler — it unwinds the goroutine’s stack frame by frame, running each frame’s deferred calls in order, until either a deferred call recovers the panic or the stack is exhausted and the program aborts. The whole dance is implemented by two runtime functions,
gopanicandgorecover, insrc/runtime/panic.go, coordinated through a per-goroutine chain of_panicrecords (panic.go @ go1.26.3). Understanding this path explains whyrecovermust sit in a deferred function exactly one frame up, why a recovered function “returns normally,” and why nested panics produce the panic traces they do. The field-level mechanics below were read directly frompanic.goandruntime2.goat the go1.26.3 tag (current stable as of 2026-05; release history).
Mental Model
Picture the goroutine stack as a vertical pile of frames, newest on top. A panic is a token that starts at the panicking frame and climbs downward toward main/the goroutine root. At each frame it pauses, runs that frame’s deferred calls one at a time, and watches each call: if a deferred call invokes recover and is eligible, the climb stops and that frame is made to return normally; otherwise the token discards the frame and moves to the next. If it reaches the bottom with no recovery, the runtime prints the panic value, dumps every goroutine’s stack, and calls exit(2).
flowchart TD P["panic(v):<br/>gopanic allocates _panic{arg:v},<br/>links it onto g._panic"] --> L["pick top-most frame<br/>with un-run defers"] L --> D{"frame has a<br/>pending defer?"} D -- yes --> R["run one deferred call"] R --> C{"did it call recover()<br/>and is it eligible?"} C -- yes --> RC["mark _panic.recovered;<br/>arrange jump to that<br/>frame's deferreturn;<br/>frame RETURNS NORMALLY"] C -- no --> D D -- "no more defers" --> N{"more frames<br/>below?"} N -- yes --> POP["discard this frame,<br/>descend one frame"] POP --> L N -- no --> A["fatalpanic:<br/>print value + all stacks,<br/>exit(2)"] style RC fill:#d4edda style A fill:#f8d7da
Diagram: the panic-unwinding loop. The insight: unwinding and running defers are interleaved per frame — the runtime does not first unwind then run cleanup; it runs each frame’s defers before discarding that frame, which is the only reason a deferred recover ever gets a chance to fire.
Mechanical Walk-through
The _panic record
Each active panic on a goroutine is represented by a _panic struct (from src/runtime/runtime2.go), linked into a stack rooted at g._panic. Quoting the definition verbatim at go1.26.3 (runtime2.go @ go1.26.3):
type _panic struct {
arg any // argument to panic
link *_panic // link to earlier panic
// startPC and startSP track where _panic.start was called.
startPC uintptr
startSP unsafe.Pointer
// The current stack frame that we're running deferred calls for.
sp unsafe.Pointer
lr uintptr
fp unsafe.Pointer
// retpc stores the PC where the panic should jump back to, if the
// function last returned by _panic.next() recovers the panic.
retpc uintptr
// Extra state for handling open-coded defers.
deferBitsPtr *uint8
slotsPtr unsafe.Pointer
recovered bool // whether this panic has been recovered
repanicked bool // whether this panic repanicked
goexit bool
deferreturn bool
gopanicFP unsafe.Pointer // frame pointer of the gopanic frame
}arg is the value handed to panic. link chains to an earlier panic — panics can nest, e.g. when a deferred call run during unwinding itself panics. The startPC/startSP pair record where _panic.start was first called (the panicking call site). The trio sp/lr/fp track which frame the runtime is currently running deferred calls for as it descends — note these are the link-register and frame/stack pointers of the current frame under examination, not a single pc. retpc is the program counter to resume at if the panic is recovered — it points at the recovering function’s deferreturn segment. deferBitsPtr/slotsPtr carry the open-coded-defer state (see defer Internals) when the current frame used open-coded defers rather than _defer records. recovered is set the moment a recover succeeds. repanicked distinguishes a fresh panic from a panic(r) re-raise; goexit marks a runtime.Goexit-driven unwinding (which shares this struct); deferreturn marks the special re-entry after a recover. Critically, gopanicFP stores the frame pointer of the gopanic frame itself — this is the anchor gorecover uses to verify recover eligibility (see below), and it is the field that replaced the older stack-pointer-comparison scheme.
gopanic — the unwinding loop
panic(v) compiles to a call to runtime.gopanic. Reading the go1.26.3 body verbatim makes the structure unambiguous:
func gopanic(e any) {
// ... validation checks (panic(nil), runtime-context guards) ...
var p _panic
p.arg = e
p.gopanicFP = unsafe.Pointer(sys.GetCallerSP())
runningPanicDefers.Add(1)
p.start(sys.GetCallerPC(), unsafe.Pointer(sys.GetCallerSP()))
for {
fn, ok := p.nextDefer()
if !ok {
break
}
fn()
}
preprintpanics(&p)
fatalpanic(&p)
}Walking it:
- Guard against runtime-internal panics. Before the loop,
panic.goconverts certain panics into unrecoverablethrows. The file’s own comment: “Many of the following panic entry-points turn into throws when they happen in various runtime contexts … if we see those functions called from the runtime package we turn the panic into a throw. That will dump the entire runtime stack for easier debugging.” A panic raised while the runtime is in a fragile state (allocating, in the scheduler) cannot be allowed to run user defers — it is escalated to an unrecoverablethrow. Since Go 1.21,panic(nil)is itself turned into a run-time error (a*runtime.PanicNilError) unlessGODEBUG=panicnil=1is set (builtin docs). - Allocate the
_panicon the stack. Notepis a local —var p _panic— not heap-allocated;p.arg = estores the argument andp.gopanicFPcaptures the caller’s stack pointer as the anchor frame. p.start(...)links the new_panicontog._panic(viap.link = gp._panic; gp._panic = &p), seedsp.lr/p.fpfrom the panicking call site, and callsp.nextFrame()to “find the first frame with a defer.”- The
for { fn, ok := p.nextDefer(); ... fn() }loop drives the unwinding. Each call top.nextDefer()returns the next deferred function to run — descending the stack frame by frame. For each frame it first drains the open-coded defers (reading thedeferBitsPtrbitmask, picking the highest set bit so calls run newest-first) and then any heap/stack_deferrecords whosespmatches the frame (d.sp == uintptr(p.sp)); when a frame is exhausted it callsp.nextFrame()to descend. The loop body simply executes each returnedfn(). - Recovery is detected through
nextDeferreturningok == falseafterrecoveredis set. A deferred call may invokerecover, which (if eligible — see below) setsp.recovered = true. The very next iteration’s bookkeeping arranges the recovering frame to resume atretpc(itsdeferreturnsegment) rather than continuing to descend, so the loop terminates and that frame performs a normal return. - If the stack is exhausted with no recovery, the loop ends with
ok == false, andgopanicfalls through topreprintpanics(&p)thenfatalpanic(&p), which prints the panic value, dumps stack traces (subject toGOTRACEBACK/ theGODEBUGknobs — see below), and exits with status 2.
gorecover — deciding eligibility
recover() compiles to runtime.gorecover. It does not itself unwind anything — it merely signals gopanic. Its critical job is the eligibility check. From the builtin docs: “Executing a call to recover inside a deferred function (but not any function called by it) stops the panicking sequence … If recover is called outside the deferred function it will not stop a panicking sequence. In this case, or when the goroutine is not panicking, recover returns nil.”
The runtime enforces this geometrically. In go1.26.3 the signature is simply func gorecover() any — it takes no argument (earlier Go versions passed an argp uintptr of the caller’s argument pointer; the current implementation does not). It reads the active panic directly: p := gp._panic, bailing out with nil if p == nil || p.goexit || p.recovered. To check eligibility it walks the stack with the runtime unwinder on the system stack, skipping FuncIDWrapper frames and counting non-wrapper frames until it reaches the gopanic frame. The decisive condition, quoted from the source:
case abi.FuncID_gopanic:
if u.frame.fp == uintptr(p.gopanicFP) &&
nonWrapperFrames > 0 {
canRecover = true
}
break loop
default:
nonWrapperFrames++
if nonWrapperFrames > 1 {
break loop
}So the runtime confirms two things: (a) the gopanic frame it reaches is the same one anchoring this panic — u.frame.fp == uintptr(p.gopanicFP), a frame-pointer comparison against the gopanicFP field, not a stack-pointer comparison against _panic.sp as older accounts (and the prior version of this note) stated — and (b) there is exactly one non-wrapper frame between gopanic and gorecover (nonWrapperFrames > 0, and the walk aborts the moment a second non-wrapper frame appears, nonWrapperFrames > 1). That single intervening frame is the deferred function itself. If both hold, gorecover sets p.recovered = true and returns p.arg; otherwise it returns nil and changes nothing. This is why a recover buried in a helper function (two non-wrapper frames — fails the > 1 check), or in a non-deferred function (the gopanic frame is never reached, or gopanicFP does not match), silently returns nil.
What “returns normally” actually means
When a panic is recovered in frame F, F does not continue from where it was — F was paused at a deferred call, and everything above F (the frames the panic already unwound through) is gone. The runtime sets the panic’s retpc to F’s deferreturn code and unwinds the stack pointer to F’s frame. The open-coded-defers proposal describes this jump precisely: “The runtime then arranges to jump to the deferreturn code segment and unwind the stack to frame F, by simultaneously setting the PC to the address of the deferreturn segment and setting the SP to the appropriate value for frame F” (open-coded defers proposal). The linker records the f-relative address of F’s deferreturn call in F’s funcInfo, which is exactly where retpc points. Execution resumes inside F’s deferreturn, runs any remaining deferred calls F still has, and then performs F’s ordinary function return — handing F’s (possibly defer-modified) named return values to F’s caller. The caller sees a perfectly normal return; it has no idea a panic occurred. This is why the recover idiom requires a named return value if it wants to surface the recovered panic as an error: the deferreturn-driven return uses whatever is in the result slots.
The order of operations at a single frame
It is worth slowing down to the per-frame level, because the interleaving is the whole subtlety. When the gopanic loop arrives at frame F (via p.nextFrame()), it does not immediately discard F. p.nextDefer() hands back F’s pending deferred calls one at a time, in LIFO order — the open-coded ones first (highest deferBits bit first) then any matching _defer records — and gopanic’s loop body runs each. After each call returns, the next p.nextDefer() re-examines p.recovered. There are three outcomes per deferred call: (a) the call did nothing relevant — nextDefer yields F’s next deferred call; (b) the call invoked recover and was eligible — recovered is now true, the loop terminates, the retpc jump into F’s deferreturn is set up, and unwinding ends; (c) the call itself panicked — a new _panic is linked via _panic.link, and the unwinding now carries both panics, continuing to unwind but with the newer panic as the “active” one. Only when F has no more pending deferred calls and none recovered does the loop advance past F (via nextFrame) to F’s caller. So a frame is always given the chance to run every one of its defers before it is abandoned — that guarantee is the contract defer relies on, and it holds even mid-panic.
Runtime-raised panics
Not every panic comes from panic(...). Out-of-bounds indexing, nil-pointer dereference, integer divide-by-zero, a failed x.(T) assertion, sending on a closed channel — the compiler inserts checks that, on failure, call runtime helpers (runtime.goPanicIndex, runtime.panicdivide, etc.) which ultimately invoke gopanic with a value implementing runtime.Error. The runtime.Error interface embeds error and adds a no-op RuntimeError() method specifically so recovered code can distinguish a runtime panic from an application panic. From the runtime docs: “a type is a runtime error if it has a RuntimeError method.” The unwinding for these is identical to an explicit panic — same gopanic loop, same recover eligibility.
Code Examples
Distinguishing runtime panics from application panics
defer func() {
if r := recover(); r != nil {
if rerr, ok := r.(runtime.Error); ok { // 1 a runtime panic
log.Printf("BUG: runtime error: %v", rerr)
panic(r) // 2 re-raise — this is a bug
}
log.Printf("handled application panic: %v", r) // 3 our own panic(...)
}
}()Line 1 type-asserts the recovered value against runtime.Error. A nil-dereference or out-of-bounds index satisfies it — line 2 re-panics, because such errors are programmer bugs that should not be silently swallowed. Line 3 handles values we passed to panic, which are deliberate.
Nested panic — a defer that panics while unwinding
func nested() {
defer fmt.Println("A") // 1
defer func() { panic("second") }() // 2 panics during unwind
defer fmt.Println("B") // 3
panic("first") // 4
}Line 4 panics. Defers run LIFO: line 3 prints B; line 2 runs and itself panics with "second" — a new _panic linked (_panic.link) to the first. The first panic is now superseded. Line 1 still runs (A). With no recovery, the program aborts and the trace shows both panics: panic: first [recovered]-style chaining followed by panic: second. This is why a deferred cleanup that can panic is dangerous — it masks the original cause.
Why a recovered function “returns normally” — the result-slot detail
When frame F recovers, F’s caller sees an ordinary return. What it returns is whatever sits in F’s result slots. For a function with unnamed results, those slots are whatever uninitialized/zero state they were left in — which is why a recover-and-continue function almost always declares named results: only a named result gives the deferred recovering closure a variable to assign. The mechanism is the same one defer Internals rule 3 describes for normal returns — return x writes the result slots, then deferred calls may rewrite them, then the function truly returns — except here there was no return statement at all; the unwinding jumped straight into deferreturn. So func F() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("%v", r) } }() ; mustWork() } works precisely because err’s slot is named and the deferred closure assigns it before deferreturn performs F’s return. Drop the name and the recovered F returns a zero error — nil — silently claiming success.
Goexit shares the unwinding path
runtime.Goexit — what terminates a goroutine when its top function returns, and what t.FailNow() uses in tests — runs the same defer-unwinding loop as a panic, which is why the _panic struct has a goexit boolean. A subtlety the runtime guards: a deferred recover() during a Goexit unwinding does not “cancel” the Goexit — the goroutine still exits. Only a genuine panic is recoverable. This matters for code that wraps test bodies in a blanket recover: t.FailNow() will still correctly fail the test.
Failure Modes and Common Misunderstandings
recover one frame too far up or down fails the geometry check. It must be the directly deferred function of the unwinding frame — the single non-wrapper frame between gopanic and gorecover. A recover in func() { helper() } where helper calls recover puts two non-wrapper frames in the chain (the deferred closure and helper), so the runtime’s nonWrapperFrames > 1 test trips and recover returns nil.
Unwinding does not cross goroutines. gopanic walks one goroutine’s stack. A panic in a child goroutine that is never recovered aborts the process; the parent’s deferred recover is on a different stack and never sees it. See Goroutine.
A panic during unwinding replaces, not nests-and-resumes. When a deferred call panics, the original panic is not “paused and resumed later” — both are now active, the trace shows the chain, and recovery semantics get subtle. Keep deferred cleanup panic-free.
Stack traces and GOTRACEBACK. On an unrecovered panic the runtime prints stacks; how much it prints is controlled by GOTRACEBACK. Per the runtime docs, the default is single — “a stack trace for the current goroutine, eliding functions internal to the run-time system, and then exits with exit code 2” — while all adds all user goroutines, system adds runtime frames and runtime-internal goroutines, and crash raises SIGABRT for a core dump. Relevant when diagnosing a production crash. See Goroutine Dumps and Stack Traces.
Abort vs. recoverable panic — throw and fatalpanic
Not every fatal condition is a recoverable panic. The runtime distinguishes two terminal paths. A panic (gopanic) is recoverable and runs user defers. A throw is not: it is the runtime’s “this is a runtime bug or an unrecoverable condition” signal — a concurrent map write detected by the race instrumentation, a stack overflow, an out-of-memory, a deadlock detected by sysmon. throw does not run user deferred calls and cannot be recovered; it goes straight to fatalthrow, freezes every goroutine, dumps stacks, and exits. The panic.go comment quoted earlier — “if we see those functions called from the runtime package we turn the panic into a throw. That will dump the entire runtime stack for easier debugging” — is the runtime demoting a would-be recoverable panic to an unrecoverable throw when it occurs somewhere too fragile to run arbitrary user code. There is also fatal, for fatal user errors (a misuse the runtime detects, like sync.WaitGroup misuse) — similar to throw but with less runtime-internal output. The practical consequence: a deferred recover is not a universal safety net. It catches panic; it does not catch a concurrent-map-write throw or a stack-overflow throw. Code that “recovers everything” still dies on those — correctly, because they are unrecoverable by construction.
Alternatives and When to Choose Them
There is no alternative unwinding mechanism in Go — gopanic is the one path. The design choice exposed to programmers is whether to use it at all: error values for expected failures, panic for bugs (see panic and recover and The Error Interface). Compared to C++/Java exceptions, Go’s unwinding is simpler — there is no per-type handler matching, no catch blocks, just “run defers, check recover” — which is deliberate: it keeps the cost model and control flow obvious. The trade-off is that you cannot selectively catch one exception type without recover-ing everything and re-panicking what you do not want.
Production Notes
The most important production fact is the asymmetry: an unrecovered panic anywhere — including a goroutine you spawned and forgot about — terminates the entire process. Long-running servers therefore wrap every spawned goroutine’s entry function in a deferred recover that logs and contains the failure, exactly because the runtime offers no per-goroutine isolation. The net/http server’s per-request recover is the canonical example. When diagnosing a crash, the panic trace’s first panic: line is the root cause; subsequent [recovered]/chained lines are panics raised during unwinding (often by buggy deferred cleanup). As of the Go 1.26.3 baseline (release history; current stable 2026-05), the gopanic loop is the post-1.21 form: a _panic.start / _panic.nextDefer / _panic.nextFrame state machine driven by the simple for { fn, ok := p.nextDefer(); ...; fn() } body, with recover eligibility anchored on the gopanicFP frame pointer rather than the older stack-pointer comparison. This restructuring was done partly to support open-coded defers and range-over-func; the observable semantics — LIFO defers during unwind, recover eligibility, process abort — are spec-stable and unchanged.
See Also
- panic and recover — the
panic/recoverbuilt-ins this note implements - defer Internals — the deferred calls
gopanicruns at each frame - Goroutine Stacks — the stack
gopanicwalks - Goroutine — panics are per-goroutine; unrecovered ones abort the process
- Goroutine Dumps and Stack Traces — reading the trace
fatalpanicprints - The Error Interface — the preferred, non-unwinding failure mechanism
- Go Internals MOC — parent map