Foundations Updated 2026-09 View as Markdown

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.

go
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:

go
for i := 0; i < 3; i++ {
    defer fmt.Println(i)
}
// prints 2, 1, 0

Arguments are evaluated immediately#

The single most common defer surprise:

go
i := 0
defer fmt.Println(i)     // captures 0 NOW
i = 42
// prints 0, not 42

The call is deferred; the arguments are not. To defer the evaluation too, wrap it in a closure:

go
i := 0
defer func() { fmt.Println(i) }()    // reads i when it runs
i = 42
// prints 42

defer in a loop leaks#

go
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:

go
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:

go
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.

go
panic("unreachable state")

Some panics come from the runtime rather than your code:

go
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 zero

Do 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:

go
var tmpl = template.Must(template.ParseFiles("index.html"))
// startup, unrecoverable, fail loudly and immediately

The 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:

go
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.

go
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#

go
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.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.