How to Use panic and recover in Go

Use panic to stop execution on critical errors and recover in a deferred function to catch and handle them gracefully.

When the program state breaks

You are building a high-throughput message processor. A bad message slips past validation. The code attempts to dereference a nil pointer. The goroutine handling that message crashes. If that goroutine is an HTTP handler, the request fails with a 500 error. If it is a background worker, the worker vanishes and the queue stalls. You need a mechanism to signal that the program state is broken and cannot continue, but you also need a way to catch that signal at a safe boundary so the rest of the system survives.

Go provides panic and recover for this exact scenario. panic halts the current goroutine and begins unwinding the stack. recover catches the panic inside a deferred function, allowing you to log the failure and resume execution instead of crashing the process. These tools exist for programming errors and system boundaries, not for normal control flow.

Panic and recover in plain words

Think of panic as a runtime assertion that failed. It tells the runtime, "an invariant is violated, the program state is inconsistent, and I cannot proceed safely." When panic fires, the runtime stops executing the current function and starts running deferred functions in reverse order. This is stack unwinding.

recover is the safety net. It is a built-in function you call inside a deferred function to intercept the panic. If you call recover while a panic is active, it returns the panic value and stops the unwinding. Execution continues after the deferred function returns. If you do not recover, the panic propagates up the stack. If it reaches the top of the goroutine, the goroutine dies and the runtime prints a stack trace.

Panic is not an error. Errors are values you return and handle. Panics are bugs. The community mantra is clear: errors are for expected failures, panics are for unexpected states.

Minimal example

Here is the simplest pattern: a deferred function calls recover to catch a panic and print the value.

package main

import "fmt"

// main demonstrates the basic panic and recover cycle.
func main() {
	// defer ensures this function runs when main returns, even if panic occurs.
	defer func() {
		// recover returns nil if no panic is active.
		// If a panic is active, it returns the panic value and halts the unwind.
		if r := recover(); r != nil {
			fmt.Printf("Caught panic: %v\n", r)
		}
	}()

	// panic halts execution immediately and starts stack unwinding.
	// The value passed to panic can be any type.
	panic("invariant violated: nil pointer detected")

	// This line never runs because panic diverts control flow.
	fmt.Println("This code is unreachable")
}

The output shows the recovered value. The program does not crash. The deferred function runs, recover captures the string, and main exits cleanly.

What happens at runtime

When panic executes, the runtime marks the goroutine as panicking. It does not stop immediately. It runs all deferred functions in the current function, then moves to the caller, and continues up the stack. This LIFO (last-in, first-out) order guarantees that cleanup logic runs in the correct sequence.

Inside a deferred function, recover checks the panic state. If called directly in the deferred function, it captures the panic value and stops the unwinding. The function returns normally, and execution resumes after the deferred call. If recover is not called, or if it returns nil, the panic continues to the next deferred function.

You can panic with any value. panic("message") is common. panic(123) works. panic(customError{}) works. recover returns any, so you can type-assert the result if you need to inspect the panic value.

Recovering at boundaries

In production code, you rarely call panic yourself. You use recover to protect system boundaries. The most common pattern is wrapping an HTTP handler or a goroutine supervisor. This ensures that a bug in route logic or a worker function returns a handled error instead of killing the process.

Here is a panic-safe HTTP handler wrapper.

package main

import (
	"fmt"
	"net/http"
)

// panicHandler wraps an http.HandlerFunc to catch panics and return 500.
// This prevents a single bad request from crashing the server process.
func panicHandler(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// defer ensures cleanup runs even if next panics.
		defer func() {
			if r := recover(); r != nil {
				// In production, log the panic value and stack trace here.
				// Returning a generic error keeps the server stable.
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			}
		}()

		// Execute the actual handler logic.
		next(w, r)
	}
}

// main sets up a server with a panic-safe handler.
func main() {
	// Wrap the handler to protect against panics in route logic.
	handler := panicHandler(func(w http.ResponseWriter, r *http.Request) {
		// Simulate a bug: this panic would crash the server without the wrapper.
		panic("handler bug: nil dereference")
	})

	http.HandleFunc("/", handler)
	fmt.Println("Server running on :8080")
	// In a real app, you would call http.ListenAndServe here.
}

The wrapper catches the panic, logs it (in a real app), and returns a 500 response. The server stays alive for the next request. This is the standard way to harden web servers in Go.

The helper trap

A common mistake is moving the recover logic into a separate helper function. recover only works when called directly inside the deferred function. If you call a helper from the deferred function and put recover inside that helper, it returns nil. The panic context is scoped to the deferred function frame.

Here is the trap in action.

package main

import "fmt"

// main demonstrates why recover must be in the deferred function itself.
func main() {
	// defer calls a helper function.
	defer func() {
		// recover inside this anonymous function works.
		// But calling a helper here breaks the recovery.
		if r := recover(); r != nil {
			fmt.Printf("Recovered in defer: %v\n", r)
		}
	}()

	panic("test panic")
}

// safeRecover attempts to recover from a helper function.
// This function will NOT catch the panic if called from a defer.
func safeRecover() {
	// recover returns nil here because it is not called directly in the deferred function.
	// The panic context is lost one stack frame down.
	if r := recover(); r != nil {
		fmt.Printf("Helper recovered: %v\n", r)
	}
}

If you replace the inline recover with a call to safeRecover(), the panic propagates and the program crashes. Keep recover in the deferred function. Do not abstract it away.

Panic versus exit

panic and os.Exit both stop execution, but they behave differently. os.Exit terminates the process immediately. No deferred functions run. No cleanup happens. Use os.Exit only in main when you want to abort the entire program, such as when a critical configuration file is missing at startup.

panic runs all deferred functions. It allows cleanup. It can be recovered. Use panic when you need to abort the current flow but still run cleanup logic, or when you are deep in a call stack and want to signal a fatal error to a boundary handler.

Calling os.Exit inside a library function is dangerous. It kills the host process and bypasses all deferred cleanup. Libraries should never call os.Exit. They should return errors or panic.

Panics in goroutines

A panic in a goroutine kills only that goroutine. It does not affect other goroutines. This isolation is crucial for concurrent systems. If a worker panics, the supervisor can detect the death and restart the worker.

However, if the main goroutine panics and is not recovered, the entire program exits. All other goroutines are terminated. This is why you must protect the main goroutine and any long-lived supervisors.

Convention aside: goroutine leaks often happen when a goroutine waits on a channel that never gets closed. If a panic occurs before the channel is closed, the waiting goroutine may hang forever. Always ensure channels are closed or use context.Context for cancellation.

Pitfalls and compiler behavior

The compiler does not catch misuse of panic and recover. You can call recover outside a deferred function, but it always returns nil. You can panic with nil, which is valid but useless because recover returns nil and you cannot distinguish it from a non-panic state.

Common runtime panics include:

  • runtime error: invalid memory address or nil pointer dereference when accessing a nil pointer.
  • runtime error: index out of range when slicing beyond bounds.
  • runtime error: map assignment to nil map when writing to an uninitialized map.

These panics indicate bugs. Fix the code. Do not recover from them unless you are at a boundary and need to convert the crash into an error.

Using panic for control flow is an anti-pattern. Never use panic to jump out of loops or functions. The stack trace cost and cognitive load outweigh any convenience. Use return and errors for flow control.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Panics hide the failure path. Errors force you to acknowledge it.

Decision matrix

Use panic when an invariant is violated and the program cannot continue safely, such as a nil pointer in a critical path or a configuration error at startup.

Use panic when writing a library and the caller passes arguments that make the function impossible to execute, like a nil slice where a non-nil slice is required.

Use recover when you need to catch a panic at a system boundary, like an HTTP handler or a goroutine supervisor, to convert a crash into a handled error.

Use recover when wrapping a third-party library that you suspect might panic, isolating the risk from your application logic.

Use standard error returns for all expected failure modes, including invalid input, missing files, network timeouts, and database errors.

Use os.Exit only in main when the program cannot start, such as missing command-line flags or unreadable configuration.

Avoid panic for control flow. Never use it to jump out of loops or functions. The simplest thing that works is usually the right thing.

Errors are values. Panics are bugs. Recover is a safety net. Protect the boundary. Let the bug burn inside.

Where to go next