sch39
Part 7 of 9 — Go Fundamentals for Developers

`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 defer to run cleanup at the end of a function.

  • Place recover at a boundary that turns a panic into a controlled error.

Implementation

Folder Structure

text
go-series/G01-07-defer-panic-recover/
├── go.mod
└── control/
    ├── control.go
    └── control_test.go

Setup

Initialize one module (run from the go-series/ folder):

bash
mkdir -p G01-07-defer-panic-recover
cd G01-07-defer-panic-recover

go mod init github.com/sch39/go-series/g0107/control

Code Blocks

Block 1: Nested defer — cleanup runs LIFO

go-series/G01-07-defer-panic-recover/control/control.go:

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:

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:

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:

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:

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:

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:

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:

bash
go test -count=1 -v ./...

Output:

text
=== 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.871s

Why 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

defer near acquisition

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 recover

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 defer immediately after.

  • Many deferred calls? Make sure the LIFO order matches cleanup dependencies.

  • Can the caller still handle the failure? Return an error, don't panic.

  • Installing recover? Put it directly in a deferred function at the correct goroutine boundary.

References