Defer, Panic and Recover
Go's cleanup mechanism, its crash mechanism, and the narrow set of cases where catching a crash is the right thing to do.
defer#
defer schedules a call to run when the surrounding function returns — by any path, including a panic.
func readConfig(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close() // runs whichever return we take
return io.ReadAll(f)
}The cleanup sits next to the acquisition, so you cannot forget it three returns later. That pairing is the whole point.
Deferred calls run last in, first out:
for i := 0; i < 3; i++ {
defer fmt.Println(i)
}
// prints 2, 1, 0Arguments are evaluated immediately#
The single most common defer surprise:
i := 0
defer fmt.Println(i) // captures 0 NOW
i = 42
// prints 0, not 42The call is deferred; the arguments are not. To defer the evaluation too, wrap it in a closure:
i := 0
defer func() { fmt.Println(i) }() // reads i when it runs
i = 42
// prints 42defer in a loop leaks#
for _, path := range paths {
f, _ := os.Open(path)
defer f.Close() // accumulates until the FUNCTION returns
}With ten thousand paths you hold ten thousand descriptors. Move the body into its own function so each iteration gets its own scope:
for _, path := range paths {
if err := process(path); err != nil {
return err
}
}
func process(path string) error {
f, err := os.Open(path)
if err != nil { return err }
defer f.Close() // now scoped to one iteration
return doWork(f)
}Capturing an error from a deferred close#
defer f.Close() discards the close error, which for a file you wrote to is the error that tells you the write failed. Use a named return:
func writeFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr // only overwrite if there was no earlier error
}
}()
_, err = f.Write(data)
return err
}A deferred closure can modify named return values, which is the mechanism that makes this work.
panic#
panic stops normal execution, runs deferred functions up the stack, and crashes the program with a stack trace.
panic("unreachable state")Some panics come from the runtime rather than your code:
var p *User
p.Name // nil pointer dereference
s := []int{1, 2, 3}
s[10] // index out of range
var m map[string]int
m["key"] = 1 // assignment to nil map
x / 0 // integer divide by zeroDo not use panic for ordinary errors. Go's convention is to return an error, and a library that panics on bad input is a library nobody can use safely. Panic is for situations where continuing is meaningless:
var tmpl = template.Must(template.ParseFiles("index.html"))
// startup, unrecoverable, fail loudly and immediatelyThe Must prefix is the convention for "this panics rather than returning an error", and it is reserved for package-level initialisation.
recover#
recover stops a panic, but only inside a deferred function:
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil
}There is essentially one legitimate use: a boundary that must not let one unit of work take down the process.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic in handler",
"err", rec, "stack", string(debug.Stack()), "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}Note what it does: logs the panic with the stack, returns a generic 500, and lets the server keep serving other requests. It does not pretend nothing happened — a recovered panic is a bug and the log line is how you find it.
Exercise#
package main
import "fmt"
func main() {
// 1. Write process(paths []string) that opens each path and closes it
// correctly WITHOUT accumulating defers.
// 2. Write safeRun(fn func()) error that runs fn and converts any panic
// into a returned error.
fmt.Println("start")
}Common questions#
Is defer expensive?#
Not meaningfully in modern Go — it is close to free in the common case. The place to think about it is inside a loop, where deferred calls accumulate until the function returns rather than the iteration ending.
When is panic appropriate in my own code?#
At startup for something unrecoverable — a template that will not parse, a required config that is absent — and for genuinely impossible states that indicate a programming error. Never for input validation or an I/O failure; those are error values.
Should I put a recover in every handler?#
One at the server boundary, as middleware, is right. Sprinkling recover through business logic hides bugs and makes debugging much harder — the panic will still be a bug, and you want it visible in your logs rather than silently swallowed mid-stack.
Get the Go agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.