How to Catch Panics in Rust with catch_unwind

Use std::panic::catch_unwind to wrap risky code and recover from panics without crashing the entire program.

When a crash isn't an option

You are building a long-running daemon that processes user uploads. One malformed file triggers a division by zero deep inside a third-party parser. The default Rust behavior is to unwind the stack and terminate the entire process. Your server dies. The load balancer notices, spins up a new instance, and users get a 502 error. You could wrap that parser in a separate process, but that adds serialization overhead and deployment complexity. You want the isolation without the process boundary.

Rust gives you a tool for exactly this scenario: std::panic::catch_unwind. It lets you intercept a panic, grab the payload, and keep the program alive. It is not a replacement for normal error handling. It is a safety net for code you do not fully control, or for scenarios where a single failure should not take down the host.

How stack unwinding actually works

When a Rust function calls panic!, the runtime does not instantly kill the process. It starts unwinding the call stack. Unwinding means walking back through each function call, running the Drop implementations for local variables, and cleaning up resources. This continues until it reaches the top of the thread. At that point, the thread terminates. If it is the main thread, the process exits.

catch_unwind inserts a checkpoint into that unwinding chain. You wrap a closure inside it. When a panic occurs inside the closure, the unwinding stops at the boundary of catch_unwind instead of continuing to the top. The runtime packages the panic payload into an Err variant and returns it to you. The stack is cleaned up only as far as the closure, and execution continues on the other side.

Think of it like a circuit breaker in an electrical panel. A short circuit normally trips the main breaker and cuts power to the whole house. A sub-panel breaker catches the fault locally, isolates it, and leaves the rest of the house running.

The minimal setup

The API is straightforward. You pass a closure to catch_unwind. The closure must return a type that implements Send + 'static. The function returns a Result. Ok contains the closure's return value. Err contains the panic payload wrapped in a Box<dyn Any + Send>.

use std::panic;

fn main() {
    // Wrap the risky code in a closure.
    // catch_unwind expects a closure that returns Send + 'static.
    let result: Result<(), &str> = panic::catch_unwind(|| {
        // Simulate a failure that would normally crash the thread.
        panic!("Something went wrong");
    });

    // Handle the outcome without terminating the program.
    match result {
        Ok(_) => println!("No panic occurred"),
        Err(payload) => println!("Caught a panic: {:?}", payload),
    }
}

The closure runs synchronously. If it completes normally, you get Ok. If it panics, you get Err. The payload type matches whatever you passed to panic!. In this case, &str.

Walking through the interception

The runtime does three things when catch_unwind catches a panic. First, it halts the automatic stack unwinding at the closure boundary. Second, it collects the panic payload and wraps it in the Err variant. Third, it runs the Drop implementations for any variables that were alive inside the closure but not yet dropped. This guarantees that memory and file handles owned by the closure are still cleaned up properly.

You can see this cleanup behavior by adding a struct with a custom Drop implementation inside the closure. When the panic triggers, Drop::drop still executes before catch_unwind returns Err. The safety guarantee holds: resources are not leaked just because you intercepted the panic.

The payload itself is opaque by default. It is a Box<dyn Any + Send>. You can downcast it to a concrete type if you know what was passed to panic!. Most of the time, you just print it, log it, or convert it into a structured error for your application layer.

Treat the catch_unwind boundary as a quarantine zone. Anything that panics inside it stays inside it.

The Send and static requirement

Rust forces the closure to return Send + 'static. This is not an arbitrary restriction. It protects you from two classes of bugs that happen when panics jump across thread boundaries or outlive their original scope.

The 'static bound means the closure cannot capture references to data that might be dropped before the panic finishes unwinding. If a panic somehow escapes the current stack frame (for example, through FFI or a custom panic handler), dangling references would cause immediate memory corruption.

The Send bound means the payload can safely move across thread boundaries. Rust's standard panic handler sometimes moves panic payloads between threads during unwinding, especially in multi-threaded runtimes. If the payload contained a non-Send type like Rc or a raw pointer, moving it would violate thread safety guarantees.

When your closure captures local variables, the compiler checks whether those variables satisfy Send + 'static. If they do not, you get a trait bound error. The compiler rejects the code with E0277 (trait bound not satisfied) because the captured environment does not implement Send.

This is where AssertUnwindSafe enters the picture. It is a zero-sized wrapper that tells the compiler you have manually verified that the captured data does not need Send guarantees for panic safety. You wrap the closure or the captured value in std::panic::AssertUnwindSafe. The compiler stops enforcing the Send bound for that specific capture.

use std::panic::{self, AssertUnwindSafe};

fn main() {
    // Rc is not Send. Capturing it directly fails the Send bound.
    let data = std::rc::Rc::new(vec![1, 2, 3]);
    let data = AssertUnwindSafe(data);

    // Wrap the closure in AssertUnwindSafe to bypass the Send check.
    let result = panic::catch_unwind(AssertUnwindSafe(|| {
        // Access the wrapped data by dereferencing.
        let inner = &*data;
        println!("Data length: {}", inner.len());
        panic!("Intentional crash");
    }));

    // Handle the result as usual.
    match result {
        Ok(_) => println!("Survived"),
        Err(_) => println!("Panic intercepted"),
    }
}

The community convention is to keep AssertUnwindSafe as close to the capture site as possible. Do not wrap entire modules or large functions. Wrap only the specific closure that needs to capture non-Send locals. The compiler's default Send requirement exists for a reason. Bypassing it requires you to mentally verify that no panic will escape into a context where thread safety matters.

A realistic plugin runner

Most Rust applications never need catch_unwind. Idiomatic code returns Result and propagates errors. But plugin systems, test harnesses, and REPLs live in a different world. They execute untrusted or dynamically loaded code. A single assertion failure in a plugin should not kill the host process.

Here is a simplified plugin executor that runs user-provided logic, catches panics, and converts them into structured errors.

use std::panic;

/// Represents the outcome of running a plugin.
enum PluginResult {
    Success(String),
    Panic(String),
}

/// Executes a plugin closure and isolates any panics.
fn run_plugin(plugin: impl FnOnce() -> String + Send + 'static) -> PluginResult {
    // Wrap the plugin in catch_unwind to intercept stack unwinding.
    let outcome = panic::catch_unwind(|| plugin());

    // Map the Result into our custom enum.
    match outcome {
        Ok(value) => PluginResult::Success(value),
        Err(payload) => {
            // Downcast the payload to a string if possible.
            let message = payload
                .downcast_ref::<&str>()
                .map(|s| s.to_string())
                .or_else(|| payload.downcast_ref::<String>().cloned())
                .unwrap_or_else(|| "Unknown panic".to_string());
            PluginResult::Panic(message)
        }
    }
}

fn main() {
    // Simulate a stable plugin.
    let stable = || "Plugin executed successfully".to_string();
    println!("{:?}", run_plugin(stable));

    // Simulate a buggy plugin that panics.
    let buggy = || {
        panic!("Buggy plugin encountered invalid state");
    };
    println!("{:?}", run_plugin(buggy));
}

The run_plugin function accepts any closure that returns a String and satisfies Send + 'static. It wraps the call in catch_unwind. If the plugin panics, the payload is downcast to a readable string. The host process continues running. You can log the failure, notify the user, and move to the next plugin.

The downcasting step is necessary because catch_unwind returns a trait object. You cannot match on &str and String directly. You check both common panic payload types. If neither matches, you fall back to a generic message. This pattern is standard in test runners and REPL implementations.

Do not use this pattern for routine error handling. Return Result instead. Reserve catch_unwind for boundaries where you cannot control the code that might panic.

Where things go wrong

Developers new to panic interception usually hit three traps.

The first trap is assuming catch_unwind catches everything. It only catches Rust panics. It does not catch std::process::abort, segmentation faults, or FFI crashes. If a C library dereferences a null pointer, the OS sends a signal. catch_unwind never sees it. The process terminates. You need signal handlers or process isolation for those cases.

The second trap is payload type mismatch. If you panic with panic!(42), the payload is an i32. If you try to downcast it to &str, you get None. The compiler will not warn you about this at compile time. The mismatch surfaces at runtime. Always downcast carefully, or just print the payload as a Debug trait object if you do not need structured data.

The third trap is forgetting that catch_unwind does not reset thread state. If a panic leaves a mutex in a locked state, or if it aborts a transaction, catching the panic does not magically fix the corruption. You still need to clean up resources manually. The Drop implementations run, but they run in a post-panic context. Some libraries do not guarantee safety after a panic.

The compiler will reject closures that capture borrowed data without AssertUnwindSafe. You will see E0277 (trait bound not satisfied) when the captured environment lacks Send. You will see E0382 (use of moved value) if you try to use a moved variable inside the closure after catch_unwind takes ownership. Read the error carefully. It points exactly to the capture that violates the bound.

Convention aside: most Rust developers reach for Result for every recoverable error. catch_unwind is considered a last resort. The community treats it like a fire extinguisher. You keep it in the kitchen, but you do not use it to cook dinner.

Choosing your panic strategy

Use catch_unwind when you are building a test runner, REPL, or plugin system that must survive untrusted code. Use catch_unwind when you are wrapping FFI boundaries where the foreign side might trigger a Rust panic through a callback. Use catch_unwind when you are implementing a sandboxed execution environment and need to isolate panics without spawning processes. Reach for Result when you are writing application logic and want explicit, composable error handling. Reach for std::process::abort when the program state is fundamentally corrupted and continuing would cause undefined behavior. Reach for AssertUnwindSafe only when you have verified that captured non-Send data cannot be accessed across a panic boundary.

Pick the tool that matches the failure mode. Do not use panic interception as a substitute for proper error propagation.

Where to go next