Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 013 / 100

panic vs Result: what every Rust developer should know

When to crash and when to return errors.

Expona Admin8 min read

The difference between panic and Result

In production systems, knowing when to crash and when to return errors separates robust code from brittle code. panic vs Result: what every Rust developer should know isn't just a nice-to-have—it's foundational to writing systems that stay running. Most junior developers treat these tools interchangeably, but they solve fundamentally different problems, and the choice between them matters enormously in production.

The core distinction is straightforward: Result is for expected failures your code can recover from, while panic is for bugs and impossible states that should never happen. When you confuse them, you either swallow critical bugs silently or crash your service over recoverable errors. This article walks through when to use each one, with concrete examples from the kinds of systems we run in production.

Result for expected failures

A file might not exist. A network request might time out. A user might enter invalid input. These aren't bugs—they're normal, expected conditions that happen in the wild. This is where Result lives.

When you return a Result, you're saying to the caller: "This operation might fail for reasons outside my control, and you need to handle it." The caller then decides what to do—retry, log, default to a fallback, or propagate the error up the stack.

Here's a practical example from a typical e-commerce checkout flow:

rust
use std::fs;

fn load_payment_config(path: &str) -> Result {
    let content = fs::read_to_string(path)
        .map_err(|e| ConfigError::FileNotFound(path.to_string()))?;
    
    let config: PaymentConfig = serde_json::from_str(&content)
        .map_err(|e| ConfigError::InvalidJson(e.to_string()))?;
    
    Ok(config)
}

fn process_payment(amount: u64, config_path: &str) -> Result {
    let config = load_payment_config(config_path)
        .map_err(|e| PaymentError::ConfigLoadFailed(e))?;
    
    // Check against payment provider...
    if amount == 0 {
        return Err(PaymentError::InvalidAmount);
    }
    
    // Process with payment gateway...
    Ok(PaymentReceipt { transaction_id: "tx_123" })
}

Notice what we're doing here. We're acknowledging that loading a config file might fail, JSON parsing might fail, and the amount might be invalid. Each of these is a plausible real-world scenario. The caller can decide whether to retry the config load, use a default payment config, or return a 400 error to the client.

In a web service, this looks like:

rust
async fn checkout(req: CheckoutRequest) -> Result {
    match process_payment(req.amount, "config.json").await {
        Ok(receipt) => Ok(HttpResponse::Ok().json(receipt)),
        Err(PaymentError::InvalidAmount) => {
            Ok(HttpResponse::BadRequest().json("Amount must be positive"))
        },
        Err(PaymentError::ConfigLoadFailed(_)) => {
            // Retry logic or fallback to cached config
            Ok(HttpResponse::ServiceUnavailable().json("Service temporarily unavailable"))
        },
        Err(e) => {
            error!("Payment processing failed: {:?}", e);
            Ok(HttpResponse::InternalServerError().json("Payment failed"))
        }
    }
}

The key principle: Result gives you granular control. The caller sees what went wrong and decides what to do about it. This is how you build resilient systems.

panic for bugs and impossible states

Now consider this: your code has a function that takes an index and accesses an array. If the index is out of bounds, that's a bug in your code—not a recoverable error. Panicking is the right choice here.

Panic is for states that should never be reachable in a correct program. Examples include:

  • Array index out of bounds (logic error in your code)
  • Mutex lock poisoned (indicates a panic inside a critical section)
  • Assertion failure in tests
  • Invariant violation (e.g., a timestamp that's somehow in the future when it shouldn't be possible)
  • Unrecoverable resource exhaustion (allocator failures)

Here's a realistic example from a trading system where order matching must maintain invariants:

rust
struct OrderBook {
    bids: Vec,
    asks: Vec,
}

impl OrderBook {
    fn match_orders(&mut self) -> Result, MatchError> {
        // Precondition: bids and asks must be sorted by price
        // This is our invariant—if it's violated, something catastrophic happened
        
        if !self.bids.is_sorted_by_key(|o| std::cmp::Reverse(o.price)) {
            panic!("Order book invariant violated: bids not sorted by descending price");
        }
        
        if !self.asks.is_sorted_by_key(|o| o.price) {
            panic!("Order book invariant violated: asks not sorted by ascending price");
        }
        
        let mut trades = Vec::new();
        
        // Matching logic...
        // If a bid price < ask price, matching stops
        if self.bids[0].price < self.asks[0].price {
            return Ok(trades); // No matching possible
        }
        
        // Execute trades
        Ok(trades)
    }
}

Why panic here instead of returning an error? Because if the order book invariants are violated, the entire system is in an unknown state. We can't trust any subsequent trades. The right thing to do is fail fast, log the panic, and let the system restart or alert on-call engineers. Trying to "recover" would just produce garbage data.

In contrast, if you called this function and the client sent you two simultaneous orders that conflict, that's not a panic—that's a recoverable error you handle by rejecting one of them with Result.

Libraries should rarely panic

This is where many junior developers get it wrong. You're writing a library (maybe a crate that other teams depend on), and you want to make it robust. Don't panic in library code. Ever.

Here's why: the caller gets to decide what "impossible" means. Your library might think a certain state is impossible in isolation, but the caller might be using your library in a way where that state actually is reachable and recoverable.

Consider a database connection pool:

rust
// BAD: Library code that panics
pub fn get_connection(&mut self) -> Connection {
    self.available.pop().expect("Connection pool exhausted")
}

// GOOD: Library code that returns Result
pub fn get_connection(&mut self) -> Result {
    self.available
        .pop()
        .ok_or(PoolError::Exhausted)
}

In the bad version, if the pool is exhausted, your library crashes whoever is using it. But the caller might have a graceful degradation strategy: queue the request, return a 503 error, try a read replica instead, or retry with exponential backoff. You've just denied them all those options by panicking.

In the good version, they get the error, see it's a pool exhaustion, and make an informed decision. Maybe they increase the pool size for the next deploy. Maybe they add circuit breaking. You've given them agency.

The rule: if you're writing library code, panic only for bugs in the caller's usage—like type misuse that the compiler can't catch. For everything else, return Result and let them decide.

unwrap only in tests and prototypes

unwrap() and expect() are the escape hatches. They say: "I'm so confident this won't fail that I'll let the whole thing crash if I'm wrong." In production code, you should almost never use them.

Where they do belong:

  • Tests: If a Result fails in a test, you want the test to fail loudly. Unwrap makes that obvious.
  • Prototypes: You're exploring an idea. Use unwrap to skip error handling for now.
  • Impossible branches: If you've genuinely proven a branch is unreachable, a well-documented expect is fine (though a comment explaining why it's impossible is more valuable).

Here's what bad unwrap in production looks like:

rust
fn sync_user_data(user_id: i64) -> UserData {
    let client = reqwest::Client::new();
    let response = client
        .get("https://api.example.com/users/{user_id}")
        .send()
        .unwrap();  // CRASHES if network fails
    
    response.json().unwrap()  // CRASHES if JSON is malformed
}

Every call to unwrap is a loaded gun. The network will fail. The API will return malformed data. Your service will crash.

Production code looks like:

rust
async fn sync_user_data(user_id: i64) -> Result {
    let client = reqwest::Client::new();
    let response = client
        .get(&format!("https://api.example.com/users/{}", user_id))
        .send()
        .await
        .map_err(|e| SyncError::NetworkError(e))?;
    
    response
        .json()
        .await
        .map_err(|e| SyncError::InvalidResponse(e))
}

Now the caller knows this can fail and handles it appropriately. If they call it from a web endpoint, they return a 500. If they call it from a background job, they retry with backoff. Your code doesn't decide their strategy—it gives them the information they need to build one.

Common mistakes

Mistake 1: Panicking on user input. A user submits an email address that fails to parse. You panic. Don't. User input is always going to be invalid sometimes. Return a Result and let the handler tell the user what went wrong.

Mistake 2: Returning Result for impossible states. Your code does something, and you've mathematically proven it can't fail. You return Ok(...) even though it's wrapped in Result. The next engineer reads your code and thinks it can actually fail, leading to defensive error handling that never triggers. If it truly can't fail, use a regular return type. If you're unsure, document the preconditions so the next person knows what can go wrong.

Mistake 3: Catching panics in production. Some teams wrap code in catch_unwind and try to recover. This is almost always wrong. Panics are for bugs. If you're catching them, you're hiding the bug, not fixing it. Let the panic propagate, crash the goroutine (or task in async), log it, and fix the root cause.

Mistake 4: Using Result for control flow. Some code uses Err(...) to break out of nested loops or jump to cleanup code. That's what try blocks are for in modern Rust. Don't abuse Result's error propagation for control flow it wasn't designed for.

Key takeaways

  • Use Result for expected failures: network issues, invalid input, missing files, rate limits. The caller decides how to handle it.
  • Use panic for bugs and invariant violations: array out of bounds, poisoned mutexes, impossible states that should never be reachable in correct code.
  • Libraries should return Result almost always. Let callers decide their recovery strategy.
  • Avoid unwrap in production code. It's a hand grenade labeled "I'm betting my uptime on this never failing."
  • Panics should crash the task or thread and be logged. Don't try to recover from them—they're signals of bugs, not error conditions.
  • Document the difference in your code. A well-named error type or a clear panic message makes it obvious why each choice was made.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles