Skip to content
GoBeginner7 min read

Defer, Panic, and Recover in Go

How defer actually schedules a call, what panic does to the call stack, why recover only works inside a deferred function, and the real-world pattern of recovering from panics in HTTP middleware — with diagrams.

GoBackend.dev
GoDeferPanicRecover

TL;DR

defer schedules a call to run when the surrounding function returns, in last-in-first-out order; panic unwinds the stack running every deferred call along the way; and recover, called directly inside a deferred function, is the only thing that can stop that unwind.

What You'll Learn

  • Exactly when a deferred call runs, and when its arguments are evaluated
  • Why multiple defers in one function run in last-in-first-out order
  • What panic actually does to the call stack as it unwinds
  • Why recover only works when called directly inside a deferred function
  • The real-world pattern: recovering from a panic in HTTP middleware so one bad request doesn't crash the server
  • How to decide whether a failure should be an error return or a panic

The problem

A function opens a file, and needs to close it — but there are three return points, two of which are error paths that would otherwise leak the open file handle:

func readConfig(path string) (Config, error) {
	f, err := os.Open(path)
	if err != nil {
		return Config{}, err
	}
	data, err := io.ReadAll(f)
	if err != nil {
		f.Close() // easy to forget on this path
		return Config{}, err
	}
	f.Close() // and easy to forget here too
	return parse(data)
}

defer exists specifically to remove this class of bug: schedule the cleanup once, right next to the thing that needs cleaning up, and it runs no matter which return point the function actually takes.

defer: scheduled for when the function returns

func readConfig(path string) (Config, error) {
	f, err := os.Open(path)
	if err != nil {
		return Config{}, err
	}
	defer f.Close() // runs no matter which return statement below executes
 
	data, err := io.ReadAll(f)
	if err != nil {
		return Config{}, err // f.Close() still runs on the way out
	}
	return parse(data)
}

defer f.Close() doesn't call Close immediately — it schedules that call to run right before readConfig actually returns, regardless of which of the two return statements gets there.

One subtlety that catches people immediately: arguments are evaluated at the defer statement, not when the deferred call later runs.

func example() {
	i := 1
	defer fmt.Println("deferred:", i) // captures i's value (1) right now
	i = 2
	fmt.Println("immediate:", i) // 2
}
// prints: immediate: 2
//         deferred: 1

i was 1 at the moment defer ran, so that's the value printed later — even though i changes to 2 before the function actually returns.

Multiple defers: last in, first out

func main() {
	defer fmt.Println("1")
	defer fmt.Println("2")
	defer fmt.Println("3")
}
// prints: 3
//         2
//         1

Each defer pushes onto a stack; when the function returns, they run in reverse order of how they were deferred. This matches the natural pattern for resource cleanup — if you open A, then open B, you want to close B first, then A, which is exactly what deferring close(A) immediately after opening it, then close(B) immediately after opening it, produces for free.

panic: unwinding the stack

panic stops the normal flow of the current function immediately and starts unwinding the call stack — running every deferred call in the current frame, then doing the same in the caller's frame, and the caller's caller, continuing until either something calls recover, or the unwind reaches main with nothing having recovered, which crashes the program with a stack trace.

Rendering diagram…

func c() {
	defer fmt.Println("c cleanup")
	panic("something went badly wrong")
}
 
func b() {
	defer fmt.Println("b cleanup")
	c()
	fmt.Println("this line never runs")
}
 
func main() {
	b()
}
// prints: c cleanup
//         b cleanup
// then crashes with the panic message and a stack trace

Every deferred call along the way still runs — panic doesn't skip cleanup, it just abandons normal control flow to get there.

recover: only works inside a deferred function

recover stops a panic's unwind and lets the program continue — but with one strict rule: it only has an effect when called directly inside a deferred function. Calling it anywhere else — in a normal function body, or even in a deferred function that itself calls another function that calls recover — does nothing and returns nil.

func safeCall() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered from:", r)
		}
	}()
	panic("boom")
}
 
func main() {
	safeCall()
	fmt.Println("main keeps running") // this DOES print
}
// prints: recovered from: boom
//         main keeps running

recover catches the panic at the level it's deferredsafeCall returns normally as far as main is concerned, and main's own flow is never interrupted.

The real pattern: recovering per-request in HTTP middleware

The single most common production use of recover is exactly the kind of server built in Understanding HTTP Servers in Go: one goroutine handles one connection, so a panic in one handler — a nil map write, an index out of range on unexpected input — would otherwise crash that goroutine and, left unhandled, take the whole process down with it. Wrapping every request in recovery middleware turns "the server crashes" into "this one request returns a 500":

func recoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("recovered from panic: %v", err)
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Every request runs through this middleware; if the wrapped handler panics, the deferred function catches it, logs it, and writes a 500 instead of letting the panic propagate up and crash the goroutine serving that connection. This is exactly the mechanism most Go web frameworks build in by default — knowing it's "just" defer and recover means you're never dependent on a framework to get this right.

Deciding: error, or panic?

  • Return an error for anything an expected, reasonable caller can hit in normal operation: a missing record, bad input, a network timeout. This is the overwhelming majority of failures — see Error Handling in Go.
  • panic for a state that means your own code's invariants are already broken — an index computed incorrectly, a required config value that was supposed to be validated already, a nil that a prior check should have ruled out. These are programmer bugs, not expected runtime conditions.
  • Recover only at a deliberate boundary — like per-request middleware — to contain the blast radius of a panic you didn't expect, not as a routine substitute for proper error handling everywhere else.

Common mistakes

  • Deferring inside a loop that runs many times, expecting each deferred call to run immediately — they all queue up and run at the end of the function, not the end of each loop iteration, which can hold resources open far longer than intended.
  • Calling recover() outside a deferred function. It silently does nothing — no compile error, no runtime error, just a nil result and the panic continues unwinding.
  • Using panic/recover as a general-purpose control-flow shortcut instead of returning errors — it obscures the failure path and is significantly slower than a normal error return.
  • Recovering and swallowing the panic silently, with no logging — turns a loud, visible crash into a quiet bug that's much harder to find later.

Summary

defer schedules a call for when the enclosing function returns, evaluating its arguments immediately but running the call later, in last-in-first-out order across multiple defers. panic abandons normal control flow and unwinds the stack, running every deferred call along the way, until recover — called directly inside a deferred function — stops it, or the program crashes. The production pattern that ties all three together is per-request panic recovery in HTTP middleware: it's what turns a single handler's bug into a 500 response instead of a crashed server.

Key Takeaways

  • defer schedules a call for when the function returns; its arguments are evaluated immediately, at the defer statement, not when it later runs
  • Multiple defer statements in one function run in last-in-first-out (LIFO) order — the most recently deferred call runs first
  • panic immediately stops normal execution and starts running deferred calls up the stack, one frame at a time, until something calls recover or the program crashes
  • recover only has an effect when called directly inside a deferred function — calling it anywhere else always returns nil and does nothing
  • Recovering from a panic in HTTP middleware, per-request, is how one handler's bug returns a 500 instead of taking the whole server down