Go defer, panic, and recover Tutorial
Learn how Go's defer, panic, and recover work together, when to use each, and how to write resilient code without abusing exceptions.
What you'll learn
- ✓How defer schedules cleanup in LIFO order
- ✓When panic is appropriate vs returning an error
- ✓How recover stops a panic inside a deferred function
- ✓Argument evaluation timing for defer
- ✓Patterns for safe goroutine boundaries
Prerequisites
- •Basic Go familiarity
What and Why
Go deliberately avoids exceptions. Most error handling uses explicit return values. But the language still ships three keywords that act like a tiny exception system: defer, panic, and recover. Together they handle resource cleanup, fatal program states, and the rare cases where you need to catch an unwinding stack at a boundary (like an HTTP handler or a goroutine root).
Understanding the trio matters because they show up in every production codebase. Misusing them is a common source of leaks and silent crashes; using them well makes code remarkably tidy.
Mental Model
defer schedules a function call to run when the surrounding function returns, regardless of how it returns. Multiple defers run in reverse order, like a stack. This is perfect for paired operations: open and close, lock and unlock, begin and commit.
panic aborts normal control flow and begins unwinding. Deferred functions still run during the unwind. If nothing intervenes, the program crashes with a stack trace.
recover is only meaningful inside a deferred function. It stops the panic and returns the panic value, letting you log it and continue. Outside defer, recover returns nil and does nothing.
The slogan: defer is for cleanup, panic is for “this should never happen,” recover is for boundaries you don’t want a panic to cross.
Hands-on Example
Here’s an HTTP-handler style boundary that logs panics, plus a file helper showing defer order and argument timing.
package main
import (
"fmt"
"os"
)
func openCopy(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil { return err }
defer in.Close()
out, err := os.Create(dst)
if err != nil { return err }
defer func() {
if cerr := out.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = io_Copy(out, in)
return err
}
func safeRun(name string, fn func()) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("[%s] recovered: %v\n", name, r)
}
}()
fn()
}
func main() {
i := 0
defer fmt.Println("deferred i =", i) // captures 0 NOW
defer func() { fmt.Println("closure i =", i) }() // sees final i
i = 42
safeRun("worker", func() {
panic("boom")
})
fmt.Println("main continues")
}
The first defer prints 0 because arguments to a deferred call are evaluated immediately; only the call itself is delayed. The closure sees 42 because it captures i by reference. safeRun swallows the panic so main keeps going.
Common Pitfalls
Argument evaluation surprises. defer log(time.Now()) records the time at the defer statement, not at the function exit. Wrap in a closure if you want late evaluation.
Defer in a loop. for _, f := range files { defer f.Close() } keeps every file open until the function returns. Refactor the loop body into a function so each iteration cleans up.
Recover in the wrong frame. recover only works in a function that was directly deferred. Calling recover from a helper called by your deferred function returns nil.
Panicking across goroutines. A panic in a goroutine that lacks its own recover crashes the whole program, no matter what the spawning goroutine does. Wrap every goroutine root with a recover if it must not bring down the process.
Using panic for validation errors. Panics are for “impossible” states (corrupt invariants, programmer bugs). For bad user input, return an error. Mixing the two confuses callers.
Practical Tips
Pair every resource acquisition with an immediate defer for release. Doing it on the next line is a habit that prevents leaks during later edits.
Use named return values when a deferred function needs to mutate the error, as in the out.Close() pattern above. It’s the cleanest way to surface cleanup failures.
At every goroutine boundary in a long-running service, install a recover-and-log defer. It turns “process crashed at 3 a.m.” into “one request failed and we know why.”
Keep panics short and informative: panic(fmt.Errorf("invariant: %v", x)). The wrapped error gives downstream recover a typed value.
Avoid catching panics deep inside business logic. Recovery belongs at boundaries: HTTP handlers, RPC entry points, background workers.
Wrap-up
defer, panic, and recover are small features with outsized impact on Go style. Use defer for guaranteed cleanup, reserve panic for genuinely exceptional invariant breaks, and place recover at well-defined boundaries. Doing so keeps your error-handling story consistent: explicit returns for the expected, panics for the impossible, and recover where the impossible must not propagate.
Related articles
- Go Go Context Cancellation Patterns
Master Go's context package: propagate deadlines, cancel goroutines safely, and avoid leaks with practical patterns for HTTP, database, and pipeline code.
- Go Go Error Handling Patterns
Idiomatic error handling in Go: sentinel values, wrapping with %w, errors.Is and As, custom types, and the structural patterns that keep code readable.
- Go Go Struct Tags and Reflection
Understand how Go struct tags work, how packages like encoding/json read them with reflection, and how to add custom tag-driven behavior to your code.
- Go Go Tooling: go vet, gofmt, and go test
A practical guide to Go's built-in tooling trio: gofmt for formatting, go vet for static checks, and go test for unit, benchmark, and coverage workflows.