14.7 When to Panic vs When to Return Result
Alright, let’s cut through the noise. The single most important decision you’ll make when your code hits a snag is this: do we burn the whole house down (panic!) or do we calmly hand the problem back to the caller (Result<T, E>)? Get this right, and your code is robust and a joy to use. Get it wrong, and you’re building a house of cards on a fault line.
The golden rule is beautifully simple: Panic when you, the programmer, have made a mistake. Return a Result when the caller might have made a mistake.
Think of it this way: a panic! is a fire alarm that empties the entire building. A Result::Err is a polite tap on the shoulder saying, “Excuse me, you seem to have given me a Tuesday, but this function only works with Wednesdays.” One is for unrecoverable, catastrophic, “this should never happen” scenarios; the other is for expected, handleable, “this is a normal part of operation” scenarios.
The Case for Panicking: Unrecoverable Errors
You should panic! when your code reaches a state that is fundamentally, provably incorrect no matter what input it gets. This is almost always a bug in your library code.
- Violated Invariants: Your function has a contract. If that contract is broken by your own logic, that’s a panic. A classic example is out-of-bounds access. You can’t return a sensible value for
vec![1, 2, 3][99]; the program’s memory is in an invalid state. So it panics. Rightly so.
// This is a panic-worthy offense. It's a programmer error.
fn calculate_ratio(numerator: f64, denominator: f64) -> f64 {
// This invariant is our responsibility to uphold.
if denominator == 0.0 {
panic!("Attempted to calculate ratio with denominator of zero. This is a bug.");
}
numerator / denominator
}
- After You’ve Already Screwed Up: If you have an
unwrapor anexpectthat fails, that’s a panic. You’re saying, “I, the all-knowing programmer, am so certain this will work that if it doesn’t, the entire universe is invalid.” Use these sparingly, only when you can genuinely guarantee success (e.g., unwrapping a hardcodedSome(5)), or in tests and early prototypes where a crash is acceptable.
The Case for Result: Recoverable Errors
You should return a Result when the error is a predictable and potentially recoverable part of normal operation. This is the caller’s problem to solve, not yours.
- Parser Problems: A user gives your function a string that’s supposed to be a number. They type “fish”. You can’t turn “fish” into 42. This isn’t your fault; it’s theirs. So you return a
Result.
// This is a Result-worthy situation. It's a caller error.
fn parse_date(input: &str) -> Result<chrono::NaiveDate, chrono::format::ParseError> {
chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d")
}
- Network or File Failures: Trying to open a file that doesn’t exist? Connecting to a server that’s down? These are routine failures in the real, messy world. The caller might want to retry, use a default, or ask the user for a different path. You give them that choice by returning a
Result.
fn load_config(path: &Path) -> Result<Config, Box<dyn Error>> {
let file_contents = std::fs::read_to_string(path)?; // The ? operator propagates the error
serde_json::from_str(&file_contents).map_err(Into::into) // Convert and propagate parsing errors
}
The Infamous “But What About…?” Scenarios
This is where it gets juicy.
Prototyping and Tests: Go ahead,
unwrap()all over the place. I do it. It’s fine. It’s fast. Just know you’re trading safety for speed. The key is to go back and replace thoseunwrap()calls with proper error handling before it becomes production code. Anexpectis slightly better because it at least gives your future self a clue (“why did I think this would never be None?”).The
todo!()Macro: This is the ultimate programmer arrogance. “I haven’t even written this code yet, but if you get here, it’s definitely an error.” It panics. And it should. The code is literally not done.In
main: Yourmainfunction is a special case. It’s often the perfect place to useunwraporexpectbecause if something critical fails right at the start of your program, you usually just want to print an error and exit (which is what a panic does). However, for a slightly more graceful exit, you can let the error bubble all the way up and useResultinmainitself.
// The quick-and-dirty (and often perfectly fine) way:
fn main() {
let config = load_config(Path::new("config.json")).unwrap();
}
// The slightly-more-elegant way (available since Rust 1.26):
fn main() -> Result<(), Box<dyn Error>> {
let config = load_config(Path::new("config.json"))?;
// ... rest of program ...
Ok(()) // Return Ok if we succeed
}
// If the ? in main returns an Err, it will print the error and exit with a non-zero code.
The bottom line? Respect your caller. Use panic! to protect your code from its own bugs. Use Result to empower your caller to handle their bugs and the world’s inherent chaos. It’s the difference between being a helpful guide and a tyrannical dictator. Choose wisely.