Skip to content
C Codeloom
Go

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.

·5 min read · By Codeloom
Intermediate 9 min read

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.

Defer stack and panic/recover flow

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.