When should I use panic vs Result

Use panic! for unrecoverable logic errors and Result for expected, recoverable failures like file I/O.

The emergency stop versus the handoff

You are building a text-based adventure game. The player types "go north". Your code checks the map data. The map says there is no door north, but your code assumes there always is. You try to access the door properties and hit a null value. The game logic is broken. You cannot ask the player to try again. The state is nonsense. The only safe move is to crash and show the developer a stack trace so they can fix the bug.

Now imagine the player tries to load a save file. The file is missing. Maybe they deleted it. Maybe the disk is full. The game can handle this. You show a message, let them pick another save, or start a new game. The program continues. The failure is expected. The caller can recover.

Rust forces you to make this distinction explicit. You cannot accidentally ignore a missing file. You cannot accidentally continue after a logic error. You choose between panic! and Result. One stops the world. The other passes the buck.

panic! is the emergency stop button. When you call it, the program unwinds the stack, cleans up resources, and terminates. Nothing after the panic runs. The process dies.

Result<T, E> is a container that holds either a success value or an error. It does not stop execution. It hands the problem to whoever called the function. The caller decides whether to handle the error, wrap it, or finally panic.

Make the error explicit. Let the caller choose the fate of the program.

How panic unwinds the stack

When panic! fires, Rust starts unwinding. It walks back up the call stack, running destructors for every local variable. This ensures memory is freed, files are closed, and locks are released. Unwinding is the default behavior because it keeps resources safe even when things go wrong.

You can configure Rust to abort immediately instead. Add panic = 'abort' to your Cargo.toml. This setting makes the program crash instantly without unwinding. The OS reclaims the memory. The binary is smaller. The crash is faster. You lose the stack trace from destructors, but you gain size and speed. Embedded systems and some high-performance servers use abort mode. Most applications stick with unwinding because the safety guarantee is worth the overhead.

/// Calculates the average of a slice, panicking if empty.
/// This function assumes the caller validates the input.
fn average(values: &[f64]) -> f64 {
    // Panic here because an empty slice is a logic error.
    // The caller should never pass an empty slice to this function.
    if values.is_empty() {
        panic!("Cannot calculate average of an empty slice");
    }

    // Sum the values and divide by length.
    // Cast length to f64 to avoid integer division.
    let sum: f64 = values.iter().sum();
    sum / values.len() as f64
}

/// Tries to parse a number, returning a Result.
/// The caller decides what to do if parsing fails.
fn parse_age(input: &str) -> Result<u8, std::num::ParseIntError> {
    // Return the error. The caller gets to decide what to do.
    // We do not panic because bad input is expected.
    input.parse()
}

Panic for bugs. Result for hiccups.

Real-world error propagation

In production code, you rarely handle errors at the point they occur. You propagate them up the call stack until you reach a layer that can do something meaningful. The ? operator makes this ergonomic. It checks the Result. If it is Ok, it extracts the value. If it is Err, it returns the error from the current function immediately.

This works because Rust can convert error types automatically if they implement From. You can use ? on a std::io::Error inside a function returning a custom error type, as long as the custom type implements From<std::io::Error>. The conversion happens behind the scenes.

use std::fs;
use std::io;

/// Configuration structure for the application.
struct Config {
    name: String,
    debug: bool,
}

/// Loads configuration from a file.
/// Returns an error if the file is missing or invalid.
fn load_config(path: &str) -> Result<Config, io::Error> {
    // Use ? to propagate errors. If this fails, the function returns early.
    // The error is returned to the caller of load_config.
    let contents = fs::read_to_string(path)?;

    // Parse the contents. If parsing fails, map the error to io::Error.
    // This keeps the return type consistent.
    let config = parse_config(&contents)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

    Ok(config)
}

/// Parses raw text into a Config struct.
/// Returns a string error for simplicity.
fn parse_config(contents: &str) -> Result<Config, String> {
    // Check for empty content.
    if contents.is_empty() {
        return Err("Config file is empty".to_string());
    }

    // Simplified parsing logic.
    // In real code, you would use a JSON or TOML parser.
    let name = contents.lines().next().unwrap_or("").to_string();
    let debug = contents.contains("debug=true");

    Ok(Config { name, debug })
}

Convention aside: The community has a strong rule for unwrap versus expect. Use unwrap() only in tests or throwaway code where you are absolutely certain the value exists. Use expect("message") in production code when you must panic. The message explains why the panic is a bug, not just that a panic happened. A message like "unwrap failed" tells you nothing. A message like "Config file must contain a valid JSON object" tells you exactly what to fix. Good expect messages turn a cryptic crash into a clear bug report.

Propagate errors up until you can actually handle them. Don't swallow them.

Pitfalls and compiler signals

Panicking in a Drop implementation is dangerous. If a value panics while being dropped, and the program is already unwinding from a panic, Rust aborts immediately. This is called a double panic. It prevents the stack from unwinding further and can leave resources leaked. The compiler cannot catch this at compile time. You have to design your Drop implementations to never panic.

Using Result for logic errors clutters your code. If you return Result for a condition that should never happen, you force every caller to handle a case that is actually a bug. This hides the invariant. The caller writes boilerplate to handle an error that will never occur in correct code. Use panic! for impossible states. Use Result for expected failures.

If you try to use the ? operator in a function that returns (), the compiler rejects it with E0277 (trait bound not satisfied). The function must return a Result or Option that matches the error type. The compiler tells you exactly which trait is missing.

Ignoring a Result triggers a warning. The #[must_use] attribute on Result tells the compiler that the value must be handled. If you call a function returning Result and don't use the value, you get a warning: unused Result. This prevents silent failures. You can suppress the warning with let _ = ..., but only if you intentionally want to ignore the error.

Never panic in a Drop implementation. The stack is already on fire.

Decision matrix

Use panic! when the program is in an unrecoverable state and continuing would cause data corruption or security issues. Use panic! for logic errors and impossible states that indicate a bug in your code, such as an index out of bounds or an empty collection where one is required. Use panic! in main functions and tests where there is no caller to handle errors. Use panic! when you are building a safe abstraction and the internal invariant is violated, because the abstraction cannot continue safely.

Use Result when the error is expected and the caller can take a meaningful action, like retrying a network request or showing a user-friendly message. Use Result for I/O operations, parsing user input, and any interaction with the outside world where failures are part of normal operation. Use Result when the function has multiple ways to fail and the caller needs to distinguish between them. Use Result when you want to compose fallible operations using the ? operator.

Use Option when the absence of a value is a normal case, like a missing optional field or a search that finds nothing. Use Option when there is no error information to convey, just the presence or absence of a value.

If you can recover, return a Result. If you can't, panic.

Where to go next