When to use panic

Use panic only for unrecoverable errors where the program cannot safely continue execution.

The moment everything stops

You are running a Go service. The configuration loads. The database connection opens. The first HTTP request arrives, hits a handler, and suddenly the entire process halts. The terminal prints a multi-line stack trace. The server is dead. This happens when a function calls panic. In Go, panic is not a normal control flow mechanism. It is an emergency brake.

What panic actually does

Think of panic like pulling the fire alarm in a building. You do not pull it because a lightbulb flickered. You pull it when the sprinklers have failed, the exits are blocked, and continuing normal operations would cause structural damage. When the alarm sounds, every floor stops what it is doing. People evacuate. The building locks down until someone resets the system.

In Go, the building is the goroutine. The evacuation is stack unwinding. When panic is called, the runtime immediately stops executing the current function. It runs every deferred function in that goroutine, starting from the most recently deferred down to the oldest. Each deferred function gets a chance to clean up resources, close files, or release locks. After all defers finish, the runtime moves up to the calling function and repeats the process. This continues until it reaches the top of the goroutine's stack. If no function along the way calls recover, the goroutine terminates. If the main goroutine terminates, the entire process exits with a non-zero status code.

A minimal example

Here is the simplest way to trigger a panic and observe the stack unwinding behavior.

package main

import "fmt"

func divide(a, b int) float64 {
	// b == 0 violates the mathematical invariant for division
	if b == 0 {
		panic("division by zero")
	}
	return float64(a) / float64(b)
}

func main() {
	// deferred function runs during stack unwinding
	defer fmt.Println("cleanup ran")
	
	// calling with zero triggers the emergency path
	result := divide(10, 0)
	fmt.Println(result)
}

Following the stack trace

When you run this program, the output shows the deferred message first, then the panic value, then a full stack trace. The runtime prints panic: division by zero followed by the call stack. Each line shows the file, line number, and function name. The trace reads bottom to top, starting from main and ending at the exact line where panic was called.

The recover function exists to catch a panic before it kills the goroutine. You only call recover inside a deferred function. If you call it outside a defer, it returns nil and does nothing. Inside a defer, it captures the panic value and stops the unwinding. Execution continues normally after the deferred function returns.

func safeDivide(a, b int) float64 {
	// defer captures the panic value if one occurs
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("caught panic: %v\n", r)
		}
	}()
	
	// triggers panic when b is zero
	return divide(a, b)
}

Recovery is rarely used in application code. It is mostly useful in HTTP servers where you want to log a bad request and return a 500 status instead of crashing the entire process. Test harnesses also use recover to catch panics and mark tests as failed without aborting the test suite.

Goroutines are cheap. Panic is not.

When panic makes sense in production

Go developers treat panic as a last resort. The language encourages returning error values for expected failures. A missing file, a bad JSON payload, or a timeout are all expected conditions. You return an error. The caller decides whether to retry, log, or fail gracefully.

Panic belongs to programming mistakes and critical initialization failures. If your code reaches a state that should be mathematically impossible, panic tells you immediately that something is broken. If your application cannot function without a specific configuration value, panic at startup is better than silently continuing and crashing later with an obscure nil pointer dereference.

Consider a server that requires a database connection string. If the environment variable is empty, the program has no fallback. It cannot start. Continuing would cause every database call to fail with confusing errors. Failing fast at the boundary is the correct design.

func loadConfig() string {
	// empty value means the program cannot operate
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		panic("DATABASE_URL must be set")
	}
	return dsn
}

This pattern works at the application boundary. Libraries should never panic on user input. A library function receives data it cannot validate fully. It returns an error. The application consumes the error and decides whether to panic. The boundary between library and application is where panic belongs.

The hidden costs and pitfalls

Panic carries performance overhead. Stack unwinding requires the runtime to walk through every frame, execute deferred functions, and tear down the goroutine's stack. This takes significantly longer than returning an error value. In a tight loop or a high-throughput handler, a single panic can degrade latency for the entire process.

Panic also breaks the standard error handling convention. Go code expects if err != nil { return err } to be the default unhappy path. The community accepts the boilerplate because it makes failure explicit and traceable. Panic hides the unhappy path behind a runtime crash. When a library panics, the caller has no chance to handle the condition. The stack trace becomes the only documentation of what went wrong.

Another pitfall involves goroutine lifecycles. If a goroutine panics and you do not recover, that goroutine dies. If other goroutines are waiting on a channel that the dead goroutine was supposed to close, they block forever. The runtime eventually detects the deadlock and prints fatal error: all goroutines are asleep - deadlock!. The worst goroutine bug is the one that never logs. Always design a cancellation path or use context.Context to shut down workers cleanly.

Context is plumbing. Run it through every long-lived call site.

Choosing panic, error, or returning early

Use panic when the program has entered an impossible state and continuing would cause data corruption or undefined behavior. Use panic at the application boundary when a required configuration value or critical resource is missing and there is no fallback. Use error when the failure is expected, recoverable, or part of normal operation. Use error in library code so callers can decide how to handle the condition. Use early return when you need to exit a function cleanly without triggering stack unwinding. Use recover only in top-level handlers like HTTP servers or test runners where you want to log the failure and keep the process alive.

Where to go next