`defer`, `panic`, and `recover`: When to Use Them
Oct 2026 · 4 min read
Use `defer` for cleanup that must run on every exit path, with LIFO execution order and arguments evaluated when the `defer` statement runs. Use `panic` only for conditions that cannot be continued, then place `recover` at a clear boundary to turn the panic into a controlled failure.
Full code: https://github.com/Sch39/go-series
Problem
Manual cleanup is easy to miss when a function has multiple return paths. An unbounded panic can also propagate along the goroutine stack and terminate the program.
Outcome
Readers can:
Explain the LIFO order and argument evaluation timing in
defer.Use
deferto run cleanup at the end of a function.Place
recoverat a boundary that turns a panic into a controlled error.
Implementation
Folder Structure
go-series/G01-07-defer-panic-recover/
├── go.mod
└── control/
├── control.go
└── control_test.goSetup
Initialize one module (run from the go-series/ folder):
mkdir -p G01-07-defer-panic-recover
cd G01-07-defer-panic-recover
go mod init github.com/sch39/go-series/g0107/controlCode Blocks
Block 1: Nested defer — cleanup runs LIFO
go-series/G01-07-defer-panic-recover/control/control.go:
package control
import "fmt"
func CleanupOrder() (events []string) {
events = make([]string, 0, 3)
defer func() { events = append(events, "first") }()
defer func() { events = append(events, "second") }()
events = append(events, "work")
return
}Goal: Show that deferred calls run in last-in, first-out order before the function finishes returning.
Block 2: Defer arguments — values are captured when the statement runs
go-series/G01-07-defer-panic-recover/control/control.go:
func EvaluatedArgument() (result string) {
value := "before"
defer func(captured string) {
result = captured
}(value)
value = "after"
return value
}Goal: Prove that the deferred function's arguments are evaluated when defer runs, not when the function is called at the end.
Block 3: Panic boundary — recover into an error
go-series/G01-07-defer-panic-recover/control/control.go:
type PanicError struct {
Value any
}
func (e *PanicError) Error() string {
return fmt.Sprintf("panic recovered: %v", e.Value)
}
func RunSafely(fn func()) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = &PanicError{Value: recovered}
}
}()
fn()
return nil
}Goal: Place recover in a deferred function at the boundary to translate a panic into a typed error.
Block 4: Panic source — a condition that cannot be continued
go-series/G01-07-defer-panic-recover/control/control.go:
func RequirePositive(value int) {
if value < 0 {
panic("negative value")
}
}Goal: Provide an operation that halts the normal flow so the panic boundary behavior can be demonstrated.
Test Blocks
Test 1: Order of deferred calls
go-series/G01-07-defer-panic-recover/control/control_test.go:
package control_test
import (
"errors"
"reflect"
"testing"
"github.com/sch39/go-series/g0107/control/control"
)
func TestDeferOrder(t *testing.T) {
got := control.CleanupOrder()
want := []string{"work", "second", "first"}
t.Logf("events=%v", got)
if !reflect.DeepEqual(got, want) {
t.Errorf("CleanupOrder() = %v, want %v", got, want)
}
}Goal: Prove that the last-registered cleanup runs first.
Test 2: Defer argument evaluation timing
go-series/G01-07-defer-panic-recover/control/control_test.go:
func TestDeferredArgumentsAreEvaluatedImmediately(t *testing.T) {
got := control.EvaluatedArgument()
t.Logf("captured=%q", got)
if got != "before" {
t.Errorf("EvaluatedArgument() = %q, want %q", got, "before")
}
}Goal: Prove that changing a variable after the defer statement does not change the argument that was already captured.
Test 3: Recover at the boundary
go-series/G01-07-defer-panic-recover/control/control_test.go:
func TestRecoverAtBoundary(t *testing.T) {
err := control.RunSafely(func() {
control.RequirePositive(-1)
})
var panicErr *control.PanicError
matched := errors.As(err, &panicErr)
t.Logf("error=%q matched=%t", err, matched)
if !matched {
t.Fatal("RunSafely() should return *PanicError")
}
t.Logf("panic value=%v", panicErr.Value)
if panicErr.Value != "negative value" {
t.Errorf("panic value = %v, want %q", panicErr.Value, "negative value")
}
}Goal: Prove that the deferred recover captures the panic value and the boundary returns it as a *PanicError.
Run Test
Run from the go-series/G01-07-defer-panic-recover/ folder:
go test -count=1 -v ./...Output:
=== RUN TestDeferOrder
control_test.go:15: events=[work second first]
--- PASS: TestDeferOrder (0.00s)
=== RUN TestDeferredArgumentsAreEvaluatedImmediately
control_test.go:25: captured="before"
--- PASS: TestDeferredArgumentsAreEvaluatedImmediately (0.00s)
=== RUN TestRecoverAtBoundary
control_test.go:39: error="panic recovered: negative value" matched=true
control_test.go:44: panic value=negative value
--- PASS: TestRecoverAtBoundary (0.00s)
PASS
ok github.com/sch39/go-series/g0107/control/control 3.871sWhy It Works
Deferred execution: The defer statement registers a function call to run just before the enclosing function returns. Because all return paths pass through this stage, cleanup can be placed close to resource acquisition without being duplicated.
LIFO and argument evaluation: Deferred calls run in LIFO order. Arguments and receivers are evaluated when the defer statement runs, so later variable changes do not replace the values already captured.
Panic unwinding: panic stops the function's normal flow and begins unwinding the goroutine stack while running deferred calls at each frame. If it is not recovered before reaching the top of the goroutine, the program terminates.
Recover boundary: recover stops the unwinding only when called directly by a deferred function in the goroutine that is panicking. A named return err lets the deferred function replace the result with a *PanicError before RunSafely finishes.
Edge Cases
Recover outside defer: Calling recover() during normal execution returns nil and has no effect.
Recover in a different goroutine: One goroutine cannot recover a panic from another goroutine; the boundary must be installed in the goroutine that runs the operation.
Nil panic: Calling panic(nil) still causes panicking; in modern Go, recover returns a non-nil value of an internal type so the panic can be distinguished from normal execution.
Trade-offs
Strategy | Pro | Con |
|---|---|---|
| Cleanup is visible and covers all return paths | Resource stays alive until the function finishes |
Manual cleanup | Release timing can be chosen precisely | Easily missed on new return paths |
Panic boundary with | Isolates failures at a clear boundary | Can hide bugs if installed too broadly |
Best practice: Use defer immediately after a resource is successfully acquired, return an error for expected failures, and use recover only at boundaries such as workers or request handlers that can handle failures meaningfully.
Production Checklist
Resource successfully acquired? Register cleanup with
deferimmediately after.Many deferred calls? Make sure the LIFO order matches cleanup dependencies.
Can the caller still handle the failure? Return an
error, don'tpanic.Installing
recover? Put it directly in a deferred function at the correct goroutine boundary.
References
The Go Blog, Defer, Panic, and Recover: https://go.dev/blog/defer-panic-and-recover
Effective Go, Defer: https://go.dev/doc/effective_go#defer
Effective Go, Recover: https://go.dev/doc/effective_go#recover
The Go Programming Language Specification, Defer statements: https://go.dev/ref/spec#Defer_statements
The Go Programming Language Specification, Handling panics: https://go.dev/ref/spec#Handling_panics