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.
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
Prerequisites
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: 1i 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
// 1Each 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 traceEvery 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 runningrecover catches the panic at the level it's deferred — safeCall
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
errorfor 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. panicfor 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, anilthat 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 anilresult and the panic continues unwinding. - Using
panic/recoveras a general-purpose control-flow shortcut instead of returning errors — it obscures the failure path and is significantly slower than a normalerrorreturn. - 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
Go Fundamentals Series
Article 6 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
Why Go treats errors as ordinary values instead of exceptions, how to build custom error types, how wrapping with %w preserves the original cause, and how errors.Is and errors.As actually work — with diagrams.
Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
What actually happens when you call http.ListenAndServe: TCP listen and accept, the goroutine-per-connection model, the Handler interface, routing, and the timeouts a real server needs — with diagrams and a runnable example.
Go Modules and Packages: Setting Up and Structuring a Go Project
What a Go module actually is, how go.mod and go.sum work, how packages and import paths map to directories, and how to structure a project before you write your first real program.