Panic in Rust: vs Result & Handling in Web Servers

Max WellsMax WellsFounder of Rustify

TL;DR: A panic in Rust is an unrecoverable error: it unwinds the call stack (running destructors), prints a message and backtrace, and terminates the current thread. Panics are for programming bugs, violated invariants, index out of bounds, unwrap() on None. They are not for expected error conditions like bad user input or missing files, use Result<T, E> for those. In web servers (Axum, Actix), panics in request handlers are caught at the thread/task boundary and converted to 500 responses, preventing one bad request from crashing the whole server.


What Is a Panic in Rust?

A panic signals that the program has reached an unrecoverable state, a bug, not a user error. Rust unwinds the stack, runs all destructors, and terminates the thread.

fn main() {
    panic!("something went terribly wrong"); // explicit panic
 
    let v = vec![1, 2, 3];
    println!("{}", v[10]); // index out of bounds; panics
 
    let x: Option<i32> = None;
    let _ = x.unwrap(); // called unwrap() on None; panics
 
    let s: &str = "abc";
    let _ = s.parse::<i32>().unwrap(); // parse error; panics
}

When a panic occurs, Rust prints:

thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10', src/main.rs:5:20

Set RUST_BACKTRACE=1 to get a full stack trace.


How Is Panic Different From Result?

Result is for expected, recoverable failures, call it "operational error." Panic is for bugs and violated invariants, call it "programmer error."

SituationUse
File not foundResult<_, io::Error>
Invalid user inputResult<_, ValidationError>
Database query failedResult<_, sqlx::Error>
Network timeoutResult<_, reqwest::Error>
Index out of boundsPanic, it's a bug
Division by zeroPanic (in debug), wraps in release
unwrap() on None you guaranteed isn't NonePanic, if it panics, your guarantee was wrong
Impossible state reachedunreachable!(), panics with a clear message
// WRONG; using panic for recoverable errors
fn read_config(path: &str) -> String {
    std::fs::read_to_string(path).unwrap() // panics if file missing
}
 
// RIGHT; return Result, let caller decide
fn read_config(path: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(path)
}

What Happens When a Panic Occurs?

By default, Rust unwinds the stack, running destructors for all values in scope, then terminates the thread. The abort profile option skips unwinding and kills immediately.

# Cargo.toml; use abort instead of unwind (smaller binary, no recovery)
[profile.release]
panic = "abort"

In a multi-threaded program, a panic in one thread does not automatically kill other threads. thread::spawn returns a JoinHandle, calling .join() returns Err if the thread panicked.

use std::thread;
 
fn main() {
    let handle = thread::spawn(|| {
        panic!("oops");
    });
 
    match handle.join() {
        Ok(_)  => println!("thread finished normally"),
        Err(_) => println!("thread panicked"), // caught here
    }
}

How Do Web Servers Handle Panics?

Axum and Actix-web catch panics at the request handler boundary and convert them to 500 responses, preventing one bad request from crashing the whole server.

use axum::{Router, routing::get};
 
async fn handler() -> &'static str {
    panic!("something went wrong"); // caught by Axum's panic handler
    "never reached"
}
 
#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(handler));
    // A panic in handler() returns HTTP 500, server keeps running
}

Axum uses tower's CatchPanic middleware under the hood. For production, install a panic hook (via std::panic::set_hook) to log panics to your monitoring system before they are swallowed.


Frequently Asked Questions

Rarely. .unwrap() is a panic waiting to happen. It is acceptable when you can prove None/Err is impossible (e.g., "42".parse::<i32>().unwrap() on a string literal), or in test code. For anything else, propagate with ?, handle with match, or use .unwrap_or().

.expect("message") is like .unwrap() but panics with a custom message. Prefer it over .unwrap(), the message explains why you believed this couldn't fail: .expect("config must be valid UTF-8"). When it does fail, you get an actionable error instead of a cryptic message.

Yes, with std::panic::catch_unwind(|| { ... }), it returns Err if the closure panicked. This is primarily for FFI boundaries and test frameworks, not for normal error handling. Do not use it as a substitute for Result.

unreachable!() is a macro that panics with the message "internal error: entered unreachable code." Use it to mark branches that should be logically impossible, if they are reached, it is a bug and the panic message makes that clear.


Sources


  • Result: The right tool for recoverable errors instead of panic
  • Option: unwrap() on None is a common panic source
  • Ownership: Panics still run destructors, owned values are always cleaned up
  • Async/Await: Panics in async tasks are caught per-task, not globally

Keep Reading

Ready to Land a $80-120k Rust Job?