defer, panic, and recover in Go: when to use

1. Overview

Go doesn’t have try/catch. Instead, the language provides three tools: defer for cleaning up resources, panic for reporting irrecoverable errors, and recover for stopping panic at a certain boundary.

This article explains the mechanism of each tool, common pitfalls, and most importantly, when to use and when not to use. After reading, you’ll know how to use defer correctly, know when panic is reasonable, and know how to use recover to keep your server from crashing due to a failing request.

2. defer

defer postpones a function call until the surrounding function ends. The function may end with a normal return or due to panic. In both cases, deferred calls are still executed.

2.1. LIFO execution order

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

Each time defer is encountered, Go pushes the call onto a separate stack for the function. When main ends, calls are popped in reverse order:

done
defer 3
defer 2
defer 1

The LIFO order has a reason. Resources opened later usually depend on resources opened earlier. For example, you open a connection and then open a transaction, so the transaction must close before the connection.

2.2. Arguments are evaluated immediately

This is the first pitfall. Arguments to a deferred function are evaluated at the moment defer is encountered, not when the call actually runs.

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

At the first call, the value 10 is captured immediately when defer is encountered. The closure in the second call is different. It references the variable total, so when it runs, it reads the latest value, which is 20.

When you need the final value, for example to measure time or log status, use a closure.

2.3. Modify return value with named result

A deferred closure can read and write named results of the function. This technique is very useful when Close() can return an error, for example when writing to a file and data is only actually flushed when closing.

package main

import (
	"errors"
	"fmt"
	"os"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

The return err statement assigns the value to the variable err, then the deferred closure runs. The closure merges the error from f.Close() into err using errors.Join, available since Go 1.20. errors.Join returns nil if both errors are nil.

If you just write defer f.Close(), errors when closing the file will be swallowed without anyone knowing.

Note: For read-only files, defer f.Close() is sufficient. Errors when closing a read-only file have almost no effect on data.

2.4. Pitfall: defer in loops

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // WRONG: only closes when processAll ends
		// ... read f ...
	}
	return nil
}

defer binds to the function, not to a block or loop. With 10,000 files, the above code keeps 10,000 file descriptors open at once and easily encounters the too many open files error.

The fix is to extract the loop body into a separate function:

func processAll(paths []string) error {
	for _, p := range paths {
		if err := processFile(p); err != nil {
			return err
		}
	}
	return nil
}

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // closes immediately when processFile returns
	// ... read f ...
	return nil
}

3. panic

panic stops normal execution of the current goroutine. Go starts unwinding the stack: each function returns upward, and the defer statements of each function still run. When reaching the top of the goroutine’s stack and no one calls recover, the program prints a stack trace and exits with code 2.

3.1. Observe the unwinding process

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

You see both defer statements run before the program dies. This is exactly why defer mu.Unlock() or defer tx.Rollback() are still safe when there’s a panic.

Panic can also come from the runtime, not just from explicit panic() calls. Common cases are accessing an index out of range, dereferencing a nil pointer, writing to a nil map, or integer division by zero.

3.2. When to panic

Go’s general rule: predictable errors should return error. For example, file not found, invalid input format, or network timeout. Only use panic in a few cases below.

1. Programming errors, that is, invariant violations. This is a state “cannot happen” if the code is correct, and continuing to run only corrupts data.

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. MustXxx functions used during initialization. The standard library has regexp.MustCompile and template.Must. You can write similar functions for configuration:

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

If the configuration is wrong, the application should not start. Panic immediately at startup helps you catch errors early, rather than letting the server run with a broken state.

Note: Don’t use MustXxx with data from users or requests. Bad input must not be allowed to crash the entire process.

4. recover

recover() stops the unwinding process and returns the value passed to panic. This function only works when called directly inside a deferred function. Called anywhere else, it returns nil and does nothing.

4.1. Convert panic to error

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

When b == 0, the runtime panics. The deferred closure catches the panic with recover() and assigns it to the named result err. The function returns normally with result keeping the zero value, which is 0.

This example is just for illustration. In practice, you should check b == 0 and return error directly.

4.2. Pitfall: recover must be called directly

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // OK: logPanic itself is the deferred function
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // DOESN'T WORK: recover is one level deeper
	}()
	panic("boom B")
}

handlerA recovers successfully. handlerB still crashes the program, because recover is called inside logPanic, and logPanic is called by the deferred function, not itself being a deferred function.

4.3. Pitfall: recover doesn’t catch panic from other goroutines

Each goroutine has its own stack. A panic in a child goroutine crashes the entire process, regardless of whether main has recover or not.

func main() {
	defer func() { recover() }() // useless for the goroutine below

	go func() {
		panic("worker crashed") // process still dies
	}()
	time.Sleep(time.Second)
}

If you run background jobs and can’t control all the code inside, wrap it:

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack() is in the runtime/debug package. It helps log enough stack trace for you to debug later.

4.4. Recover at HTTP boundary

A reasonable place to put recover is at the request boundary. One failing request should not drag down other requests.

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // let net/http handle abort as designed
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http already recovers panic for each connection, but it only logs then closes the connection, so the client doesn’t receive a proper response. The middleware above returns a clear 500 status, and also logs with method and path for easy lookup.

5. Write tests for panic and recover

Go uses the standard testing package, no external dependencies needed.

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if result != 0 {
		t.Errorf("expected result 0, got %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("unexpected error message: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("expected panic, got none")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("unexpected panic value: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("expected 8080, got %d", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

The second test uses defer and recover itself to assert the function panics with the correct message. If you use testify, assert.PanicsWithValue gives similar results with less code.

6. When NOT to use

  • Don’t use panic/recover like exceptions. Writing panic(ErrNotFound) then recovering at a higher layer makes control flow hard to read, and goes against Go conventions. Return error instead.
  • Don’t panic in public libraries when the error comes from the caller’s input. Users of your library don’t expect their process to die.
  • Don’t recover and silently ignore. At least log the panic value and stack trace. If the state is corrupted, for example shared data is partially written, let the process die and have the orchestrator restart it.
  • Don’t defer in long loops (see section 2.4).
  • Don’t worry too much about defer performance. Since Go 1.14, open-coded defer costs almost nothing in most cases. Only optimize when profiler points to it as a hotspot.

Note: Since Go 1.21, panic(nil) no longer makes recover() return nil. Instead, recover() returns *runtime.PanicNilError. Old code using recover() == nil to check “no panic” is now more accurate.

7. Conclusion

These three tools have very different roles:

  • defer is an everyday tool for cleaning up resources. Remember two things: arguments are evaluated immediately when defer is encountered, and defer binds to the function, not to a loop. Combined with named results, defer helps you not miss errors from Close().
  • panic is for programming errors and unrecoverable initialization errors. For other errors, return error.
  • recover should only appear at boundaries: HTTP middleware, worker goroutines, or where you call code you don’t control. It must be called directly in a deferred function, and only catches panic from that very goroutine.

Understanding these boundaries, you’ll write Go code that is both safe and in the spirit of the language: errors are values, while panic is a true exception.

golangdeferpanicrecovererror-handling