Three keywords work together to manage cleanup and exceptional situations in Go: defer schedules a function call for when the surrounding function returns; panic unwinds the call stack signaling an unrecoverable situation; recover catches a panic inside a deferred function and lets the program continue.
In practice, defer is used constantly for cleanup. panic is used sparingly — only for programming errors where continuing is impossible. recover appears at the edges of your system (HTTP handlers, goroutine entry points) to prevent one failure from crashing everything.
For normal error handling see Go error handling and Go custom errors.
How defer Works
A deferred function call is pushed onto a stack. When the surrounding function returns — normally, via return, or because of a panic — all deferred calls execute in last-in, first-out (LIFO) order.
The most common use is resource cleanup: open a resource, immediately defer its close, then forget about it:
func readConfig(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening config: %w", err)
}
defer f.Close() // runs when readConfig returns, no matter what
return io.ReadAll(f)
}
This pattern is reliable because defer fires on every exit path — normal return, early return from an error check, and even panic. Without it, any early return would leak the file descriptor.
Multiple defers stack up and run LIFO — useful for paired operations like lock/unlock:
func process(mu *sync.Mutex, cache map[string]string) {
mu.Lock()
defer mu.Unlock() // runs last (LIFO)
f, _ := os.CreateTemp("", "proc-*")
defer f.Close() // runs first (LIFO)
defer os.Remove(f.Name()) // runs second — removes file after Close
// work with cache and f
}
The Remove/Close ordering matters: you should close the file before removing it on some systems. LIFO makes this natural — defer the last operation first.
Argument Evaluation: Captured at Defer Time
Defer’s arguments are evaluated when the defer statement executes, not when the deferred function runs. This distinction matters:
func example() {
x := 1
defer fmt.Println("x =", x) // x is evaluated NOW: prints "x = 1"
x = 100
fmt.Println("end") // prints "end"
}
// Output:
// end
// x = 1
The deferred fmt.Println captured x = 1 at the moment the defer statement ran. The subsequent x = 100 doesn’t affect it.
This is often the right behavior — you want to log the request ID at the point of the deferred call, not whatever value it might have later. But it can surprise you when you intend to log final values. Use a closure to capture by reference:
func example() {
x := 1
defer func() { fmt.Println("x =", x) }() // x evaluated when closure runs
x = 100
}
// Output: x = 100
Named Return Values and Defer
Deferred functions can read and modify named return values, which enables a clean pattern for wrapping errors with context:
func openDB(dsn string) (db *sql.DB, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("openDB: %w", err) // wrap whatever error was returned
}
}()
db, err = sql.Open("postgres", dsn)
if err != nil {
return // defer runs, wraps the error
}
if err = db.Ping(); err != nil {
return // defer runs, wraps the error
}
return // defer runs, err is nil — no wrapping
}
The deferred closure sees the named return err by reference. When the function returns with an error, the defer wraps it. When it returns successfully, err is nil and the defer does nothing. This avoids repeating fmt.Errorf("openDB: %w", err) at every error return.
panic: Signaling Unrecoverable Errors
panic stops the normal execution of a function and begins unwinding the call stack, running deferred functions along the way. If nothing recovers it, the program crashes with a stack trace.
Use panic only for conditions where continuing execution would be wrong — programmer errors, violated invariants, impossible states:
// mustParseURL panics if the URL is invalid — only for hardcoded strings
func mustParseURL(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
panic(fmt.Sprintf("invalid hardcoded URL %q: %v", raw, err))
}
return u
}
// Package-level initialization — if this fails, the program shouldn't start
var apiBase = mustParseURL("https://api.example.com/v1")
The Must naming convention (used in regexp.MustCompile, template.Must) signals that a function panics on error and is intended only for values that are known correct at compile time or startup.
For runtime errors — missing files, bad user input, network failures — return error values. Panicking on user input is incorrect and will crash your service.
recover: Catching Panics at Boundaries
recover stops a panic and returns the value passed to panic. It only works inside a deferred function — calling recover anywhere else returns nil and does nothing.
The canonical use is at service boundaries to prevent a panicking request from crashing the entire process:
func safeHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// Log the panic with a full stack trace for debugging
log.Printf("panic recovered: %v\n%s", rec, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
This pattern — recover at the top of a goroutine, log the panic and stack trace, return an error response — is what frameworks like Gin include by default (gin.Recovery()). For raw net/http, you add it as middleware.
The same pattern applies to goroutine entry points. A panic in a goroutine that isn’t recovered crashes the whole program, not just that goroutine:
func safeGo(fn func()) {
go func() {
defer func() {
if rec := recover(); rec != nil {
log.Printf("goroutine panic: %v\n%s", rec, debug.Stack())
}
}()
fn()
}()
}
When recover Should Not Be Used
Recovering from all panics indiscriminately hides bugs. If code panics with a nil pointer dereference, recovering from it means the program continues with corrupted state. The right response to most panics is to let them surface during development so you can fix them.
Recover is appropriate at:
- HTTP handler wrappers — prevent one bad request from killing the server
- Goroutine entry points — prevent one worker from crashing everything
TestMain— prevent a panicking test setup from crashing the whole test suite
It’s not appropriate as a general error-handling mechanism within a function. Don’t use panic/recover as a non-local return:
// ❌ Don't do this — use error returns instead
func parse(s string) {
if !valid(s) {
panic("invalid input")
}
}
func caller() {
defer func() {
if r := recover(); r != nil {
// This is just error handling disguised as panic/recover
}
}()
parse(userInput)
}
Practical Pattern: Transaction Rollback
A common defer pattern is ensuring a transaction rolls back if an error occurs, but commits if everything succeeded:
func transfer(db *sql.DB, from, to string, amount int) (err error) {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if err != nil {
// Named return err is set — rollback
if rbErr := tx.Rollback(); rbErr != nil {
err = fmt.Errorf("rollback after %w: %v", err, rbErr)
}
}
}()
if _, err = tx.Exec("UPDATE accounts SET balance=balance-? WHERE id=?", amount, from); err != nil {
return fmt.Errorf("debit: %w", err)
}
if _, err = tx.Exec("UPDATE accounts SET balance=balance+? WHERE id=?", amount, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
return tx.Commit()
}
The deferred function checks the named return err. If any step set it, the rollback runs. If Commit succeeds, err is nil and the defer does nothing.
Summary
deferruns at function exit (all paths, including panic), LIFO if multiple defers- Arguments to defer are captured at the
deferstatement, not at execution — use a closure to capture by reference - Named return values are accessible (and modifiable) by deferred closures — useful for error wrapping
panicis for unrecoverable programmer errors, not runtime errors that should be returned aserrorrecoveronly works in a deferred function; use it at service/goroutine boundaries, not as general control flow- Always log the stack trace (
debug.Stack()) when recovering from a panic — without it, the crash is invisible
Comments