Skip to content
gohacks.dev
Basics

Re Panic and Cleanup Patterns With Recover in Go Code

Re-panic in Go after recover to run cleanup, add logging or handle only the panics you expect, and see how the trace looks when the value is raised again.

4 min read
Summarize in AI
Flat illustration of an editor window full of code.

recover stops a panic, which is sometimes more than you want. A function may need to react to a panic, to log it, release something or translate one specific kind, and still let every other kind keep travelling up the stack. Go has no "catch only this" syntax, so the technique is to recover and raise the value again. It is a small pattern worth getting right, because done carelessly it throws away the information that makes a panic debuggable.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This is a companion to recover from panics and panic or error, and part of the error handling guide. Everything ran on Go 1.25.6.

Handle one kind, pass the rest

A package that uses panics internally, for example a recursive-descent parser that panics to unwind out of deep nesting on a syntax error, should convert its own panics into errors at its public boundary and leave genuine bugs alone. The way to tell them apart is to give your panics a type:

main.goGo
package main

import (
	"fmt"
)

type parseError struct{ msg string }

func parse(input string) (result string, err error) {
	defer func() {
		r := recover()
		if r == nil {
			return
		}
		if pe, ok := r.(parseError); ok {
			err = fmt.Errorf("parse: %s", pe.msg)
			return
		}
		panic(r) // not ours: a bug elsewhere, pass it on
	}()

	if input == "" {
		panic(parseError{"empty input"})
	}
	if input == "crash" {
		var m map[string]int
		m["x"] = 1
	}
	return "ok", nil
}

func main() {
	fmt.Println(parse("fine"))
	fmt.Println(parse(""))

	defer func() { fmt.Println("main saw:", recover()) }()
	fmt.Println(parse("crash"))
}
Text
ok <nil>
 parse: empty input
main saw: assignment to entry in nil map

The deliberate parseError becomes an ordinary error for the caller. The nil map write, a real bug, is re-raised with panic(r) and lands in the recover in main, which stands in for whatever top-level boundary your program has. Without the type check, the parser would turn every runtime error inside it into a polite parse: message and hide bugs as bad input.

What the trace looks like

When a re-panicked value is not recovered by anyone, the runtime prints the chain. This program recovers, logs, and raises again:

main.goGo
package main

import "fmt"

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("cleanup after:", r)
			panic(r)
		}
	}()
	panic("disk on fire")
}
Text
cleanup after: disk on fire
panic: disk on fire [recovered, repanicked]

goroutine 1 [running]:
main.main.func1()
	example.test/lab/main.go:9 +0x80
panic({0x100792280?, 0x1007ad010?})
	runtime/panic.go:783 +0x120
main.main()
	example.test/lab/main.go:12 +0x48
exit status 2

The first line of the output is cleanup after: disk on fire, the one the deferred function printed. The second is panic: disk on fire [recovered, repanicked]. The marker is how Go 1.23 and later report a value that was recovered and then raised again with panic(r): it shows one panic and not two, and the trace that follows keeps both locations. The first frame is the deferred function at line 9 where panic(r) ran, and further down is the panic call itself and main.main at line 12, the original site. Nothing was lost by recovering. If you had wrapped the value in a new one, as in panic(fmt.Sprintf("wrapped: %v", r)), the runtime would see an unrelated second panic, and the original would print only as text. So pass the same value when you re-panic, unless you have a reason to wrap it, and when you must, include the original with %v or use an error with %w.

To keep the original stack frames available in your own logs, capture debug.Stack() before you re-panic and write it out. debug.Stack() inside the deferred function still shows the frames of the code that panicked, because unwinding has not finished (stack traces for errors).

Cleanup does not need recover

A mistake that looks like this pattern is recovering and re-panicking just to run cleanup code. Deferred functions already run while a panic unwinds, so a plain defer is enough:

main.goGo
package main

import "fmt"

func work() {
	fmt.Println("open resource")
	defer fmt.Println("release resource")
	panic("work failed")
}

func main() {
	work()
}
Text
open resource
release resource
panic: work failed

goroutine 1 [running]:
main.work()
	example.test/lab/main.go:8 +0x90
main.main()
	example.test/lab/main.go:12 +0x1c
exit status 2

The output shows open resource, then release resource, then the panic message and trace, and exit status 2. The resource was released without any recover, and the original panic continued untouched with its full trace. Reach for recover only when you need to look at the value, decide something based on it, or stop it.

Rules for re-panicking

  • Re-raise the same value so the trace and the type survive.
  • Recover narrowly. Check the type first, and re-panic everything you do not own.
  • Don't re-panic in a loop. A deferred function that re-panics on each iteration of something inside a larger recover boundary creates a confusing trace.
  • Prefer not to recover at all when a deferred cleanup is enough.

The specification's section on handling panics and the Go blog's Defer, Panic, and Recover describe the mechanism itself.

Taggedquick tip
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.