Don't Panic - Guidelines for Using panic! in Rust

Practical guidelines for deciding when to return Result and when to use panic! in Rust, with techniques to avoid unnecessary panics through type-system design.

The Rust error handling landscape offers two clear paths: Result for recoverable failures and panic! for unrecoverable bugs. A natural impulse, especially for developers coming from exception-based languages, is to treat panic! as a catch-all for anything that goes wrong. That impulse leads to libraries that crash on unexpected input and applications that cannot adapt to transient failures.

These guidelines explain where panic! genuinely belongs and, more importantly, how to structure your code so that panicking becomes an extremely rare event.

Returning Result as the Default

When a function can fail, and that failure is part of normal operation, return Result. This rule is the foundation of Rust error handling.

The reason is simple: Result gives the calling code a choice. The caller can retry, fall back to a default, propagate the error, or—if the situation truly warrants it—convert the error into a panic themselves. When you call panic! inside a function, you take that choice away from every caller, regardless of their context.

Consider a function that reads a configuration file. A missing file is not a bug; it is a normal operational state that might occur when the user runs the program for the first time.

use std::fs;
use std::io;
fn load_config(path: &str) -> Result<String, io::Error> {
    fs::read_to_string(path)
}

This function returns Result. If the file doesn’t exist, the caller can create a default configuration and continue. If the function panicked instead, that caller would have no way to recover; the program would simply crash.

Expected Failures Return Result:

A file not found, a network timeout, or a malformed user input is not a bug. These are expected runtime conditions. Return Result for all of them.

The default decision, when you are unsure, is to return Result. This keeps your code composable and leaves the final decision about what constitutes an unrecoverable error in the hands of the code that has the most context: the caller.

Situations Where panic! Is Justified

panic! exists for one purpose: to signal that a fundamental assumption has been violated and the program cannot safely continue. These situations are called bad states. The Rust Book describes three conditions that must all be true before a panic! is the right call.

  1. The bad state is unexpected—not something that happens occasionally in normal operation.
  2. The code that follows relies on the assumption that the bad state cannot occur, and checking for it at every step would be impractical.
  3. There is no good way to encode the invariant in the type system.

When all three conditions hold, panicking alerts the developer to a bug early and prevents undefined behavior or silent data corruption.

An example is the standard library’s vector indexing. Accessing an index out of bounds violates the assumption that the index is valid. The compiler cannot prove that the index will always be in range, and checking the length before every access would be both verbose and a performance regression for code that already knows the bounds. The operation is also unsafe if allowed to proceed—reading arbitrary memory leads to security vulnerabilities. So the standard library panics.

let v = vec![10, 20, 30];
let x = v[99]; // panics: index out of bounds

In your own code, a similar scenario might involve an invariant that a buffer’s size must be a power of two. If some internal computation produces a non-power-of-two size due to a logic error, panicking immediately surfaces the bug rather than allowing corrupted data to propagate.

Panic for Bugs, Not for Errors:

A panic! should always indicate a bug in the program—a programmer mistake. It should never fire because of bad user input, a missing file, or an external service being unavailable.

Panicking in Examples, Prototypes, and Tests

Three contexts make unwrap and expect—and by extension panic!—perfectly acceptable, even when they would be inappropriate in production code.

In examples, verbose error handling distracts from the concept being taught. Using unwrap is understood to be a placeholder for whatever error handling a real application would need.

During prototyping, you often do not yet know how errors should be handled. unwrap and expect leave visible markers in the code—when you later search for unwrap you can replace each one with proper error handling.

In tests, a failure should halt the test immediately. assert!, unwrap, and expect all panic on failure, which is exactly the behavior a test framework needs to mark a test as failed.

#[test]
fn test_parse_valid_input() {
    let result: u32 = "42".parse().expect("valid input should parse");
    assert_eq!(result, 42);
}

This test panics if the string fails to parse. That is the desired outcome: the test fails, and the developer investigates.

When You Have Information the Compiler Lacks

Sometimes you know, through reasoning that the compiler cannot replicate, that a Result will always be Ok in a particular context. In those cases, expect is appropriate—provided you document your reasoning in the message string.

use std::net::IpAddr;
let home: IpAddr = "127.0.0.1"
    .parse()
    .expect("hardcoded IP address should be valid");

The string "127.0.0.1" is known to be a valid IP address. The compiler still requires you to handle the Result because parse returns Result in the general case. Using expect here confirms the invariant and documents it. If someone later changes the code to parse a user-provided string, the expect serves as a red flag that the assumption is no longer valid.

Document Assumptions with expect:

When you call expect, the message you write becomes part of the panic output. It tells the next developer exactly why you believed this operation could not fail.

Leveraging the Type System to Avoid Runtime Panics

The most effective way to eliminate panics is to make invalid states unrepresentable. If a value that violates an invariant cannot even be constructed, no runtime check is necessary.

Take a function that requires a positive integer. One approach is to accept an i32 and panic if it is zero or negative:

fn process_count(n: i32) {
    if n <= 0 {
        panic!("count must be positive, got {n}");
    }
    // ... use n safely
}

This is fragile. Every function that needs a positive integer must perform the same check, and a slip-up means a panic at runtime. A better approach creates a type that can only hold valid values.

#[derive(Debug, Clone, Copy)]
pub struct PositiveU32(u32);
impl PositiveU32 {
    pub fn new(value: u32) -> Option<Self> {
        if value == 0 {
            None
        } else {
            Some(Self(value))
        }
    }
    pub fn get(&self) -> u32 {
        self.0
    }
}
fn process_count(count: PositiveU32) {
    let n = count.get();
    // n is guaranteed positive; no check needed
    println!("Processing {n} items");
}

The constructor new returns Option<PositiveU32>. A caller who passes zero gets None and must handle it—no panic occurs. Any function accepting PositiveU32 can rely on the invariant without checking. The type system enforces the contract at compile time.

This pattern generalizes to any domain constraint: non-empty strings, constrained numeric ranges, validated email addresses. The validation happens once, at the boundary where data enters the system, and the rest of the code remains clean and panic-free.

Never Panic on User-Provided Data:

Panicking because a user typed an invalid number or a malformed email address turns a recoverable input error into a crash. Use fallible constructors that return Result or Option instead.

Common Misconceptions About panic!

One persistent misconception is that panic! behaves like exceptions in other languages and that you can catch them to recover. Rust does provide std::panic::catch_unwind for this purpose, but it is not intended for normal error handling. It exists for use in test harnesses, FFI boundaries, and situations where you must prevent a panic from crossing into code that cannot safely unwind. Using catch_unwind as a general-purpose error recovery mechanism is a sign that panic! is being used where Result belongs.

Another misconception is that libraries should panic liberally because callers can always catch. In practice, callers cannot predict which functions might panic, and hunting down panics in a large codebase is difficult. Libraries that panic on recoverable failures are notoriously frustrating to use because they give callers no control.

The final misconception is that avoiding panic! means writing verbose error-handling code everywhere. The ? operator, combined with well-chosen error types, makes propagation concise. By pushing validation into types, you reduce the number of error paths rather than increasing them.

Summary

The guidelines for panic! boil down to a single principle: panic only when continuing would be unsafe or meaningless due to a programmer error, and only when the type system cannot prevent that error at compile time. For everything else, return Result.

Choosing Result as the default keeps your code reusable and predictable. Investing in new types that encode invariants eliminates entire categories of runtime checks and panics. When a panic does occur, it should be a loud, unambiguous signal that a bug exists—not a substitute for normal error handling.