skip to content

In Go, how do you capture a panic's stack trace inside the deferred function that recovers it?

level: juniorimportance: must knowfreq 58%

answer

  1. recover gives you the value, not the place
  2. one small package in the standard library
  3. one call returns bytes, one writes to stderr
  4. grab it while the frames are still there

basics

~10 s

Call runtime/debug.Stack() inside the deferred function. It returns the calling goroutine's formatted stack trace as a byte slice, still containing the frames that panicked. debug.PrintStack() does the same but writes straight to standard error.

solid answer

~40 s

`recover()` hands you only the panic value, never the location, so you have to capture the stack yourself. Inside the deferred function call `debug.Stack()` from `runtime/debug`: it returns a `[]byte` holding the formatted trace of the current goroutine, which you attach to a log line together with the recovered value. The trace is useful because the panicking frames are still on the stack while deferred calls run, so you see `runtime.gopanic` and the function that actually blew up beneath your handler. `debug.PrintStack()` is the same trace written directly to standard error, which is fine for a scratch program but poor in a library, because it hardcodes the destination. Capture it inside the deferred function: once the handler returns, unwinding completes and those frames are gone.

code

go · 6 lines
go
defer func() {
	if r := recover(); r != nil {
		// debug.Stack() returns []byte; %s prints it as text.
		log.Printf("recovered: %v\n%s", r, debug.Stack())
	}
}()

go deeper

for a junior

Be ready to name the package and the two calls: runtime/debug, Stack for bytes and PrintStack for standard error, used inside the deferred function next to recover.

for a middle

Explain why the panicking frames are still visible from the handler, and why capturing later loses them. Know that debug.Stack grows its own buffer while runtime.Stack does not.

for a senior

Show the judgment around it: recover at a request boundary only, log the value and the stack together with a request id, and rate-limit the output so a panic storm does not become a log outage.

for a principal

Frame it as a policy question. Decide where in a codebase recovery is permitted at all, what a captured stack must be attached to before it is useful, and how panic traces reach diagnostics without leaking into user-visible responses.

## The problem When a Go program panics, the runtime walks up the goroutine's stack running deferred functions. A deferred function may call the builtin `recover()`, which stops the panic and returns the value that was passed to `panic(...)`. That value is all you get. If the panic was `panic("index out of range on shard map")`, you learn the message and nothing about *where* it happened. Logging only the recovered value produces the single most useless class of production log line: a message with no location. ## The two functions in runtime/debug The standard library answer lives in the `runtime/debug` package: - `debug.Stack() []byte` returns a formatted stack trace of **the goroutine that calls it**, as bytes. - `debug.PrintStack()` writes that same trace to standard error and returns nothing. Both are thin wrappers over `runtime.Stack`, which formats a trace into a caller-supplied buffer. `debug.Stack` picks the buffer for you: it starts at 1 KB and doubles until the whole trace fits, so you never get a silently clipped trace. The idiomatic recovery handler therefore looks like this: ```go defer func() { if r := recover(); r != nil { log.Printf("recovered: %v\n%s", r, debug.Stack()) } }() ``` Note `%s` on a `[]byte`: the trace is already formatted text, one line per frame pair (function, then file and line). ## Why the trace still shows the panicking function This is the part that surprises people, and it is the reason the pattern works at all. Deferred functions are not run after the stack has been unwound; they are run **by** the panic machinery while the frames beneath are still live. The runtime's `runtime.gopanic` was called from the function that panicked, and it in turn calls your deferred function. So the walk from your handler outward reads roughly: your deferred closure, then `runtime.gopanic`, then the function that panicked, then its callers. You get the fault site for free. The corollary is a real bug people ship: if you only remember the panic value inside the handler and format the stack somewhere later — after the deferred function returns, or in a goroutine you start from it — the panicking frames are gone, and you capture the stack of wherever you happen to be standing. Capture the bytes inside the deferred function, then pass those bytes around. ## Choosing between Stack and PrintStack `PrintStack` hardcodes standard error. In a library or a service with structured logging that is the wrong destination: you cannot attach a request id, you cannot rate-limit it, you cannot drop it in a test. `Stack` returns bytes, which the caller routes wherever it wants — a log field, an error report, a file, `io.Discard`. Prefer `Stack` in anything other than a throwaway program. The trace is per goroutine and only the current one. Nothing here shows you what the rest of the process is doing; that needs `runtime.Stack(buf, true)` or a goroutine profile, both of which pause the program and are a deliberate, separate decision. ## Cost and discipline Formatting a stack is not free — it walks frames and builds text — but a recovery handler runs once per panic, and a service that panics often enough for the formatting cost to matter has a much larger problem. What *does* matter is volume of output: an unbounded flood of multi-kilobyte traces can fill a disk or a log budget, so rate-limit the log call rather than the capture. Finally, recovering is a decision, not a reflex. A recovery handler that swallows the panic and logs a stack is appropriate at a request boundary, where one bad request should not take the process down. Recovering deep inside library code, where the caller's invariants are already broken, hides a bug. The stack you captured is what makes the difference reviewable: it says exactly which invariant broke and where.

  • Why does the trace captured in that handler still contain the function that panicked?
    Because deferred functions run before the frames are discarded. The panicking function called `runtime.gopanic`, and `runtime.gopanic` calls your deferred function, so the walk outward is: your closure, `runtime.gopanic`, the function that panicked, then its callers. The fault site is right there in the trace.
  • What breaks if you store the recovered value and format the stack after the deferred function returns?
    You lose the fault site. Once the handler returns, the panic is finished and those frames are gone, so a later `debug.Stack()` shows wherever you are standing now. Capture the `[]byte` inside the deferred function and pass the bytes along.
  • Why prefer debug.Stack() over debug.PrintStack() in a library?
    `PrintStack` hardcodes standard error, so a caller cannot attach a request id, route it to structured logging, rate-limit it, or silence it in tests. `Stack` returns bytes and leaves the destination to the caller, which is the right default for code other teams import.

recover() is the alarm telling you there was a fire; debug.Stack() is the photograph of the room taken while it is still burning. Leave the deferred function first and there is nothing left to photograph.

saying these in an interview costs you the question

  • Claiming recover() returns the stack trace as well as the value
  • Re-panicking just to make the location appear somewhere
  • Saying debug.PrintStack writes to standard output
  • Formatting the stack after the deferred function has returned
  • Believing debug.Stack() covers every goroutine in the process