The backup plan problem
You're writing a CLI tool. The user can pass a --port flag. If they don't, you want to use 8080. Easy enough. Now imagine the config file is missing. You don't just want a default; you want to print "Config not found" and exit. Or maybe you're fetching a user profile and want to create a guest profile on the fly if the ID is invalid. The pattern is the same: you have a value that might be missing, and you need a fallback. The fallback might be a constant, or it might require work.
Rust gives you two methods for this exact situation: unwrap_or and unwrap_or_else. They look similar, but they behave differently under the hood. Picking the wrong one wastes CPU cycles or forces you to write ugly code. Choosing the right one makes your intent clear and keeps your program fast.
Choose the tool that matches the cost of your backup plan.
Lazy evaluation saves work
unwrap_or takes a value. unwrap_or_else takes a closure. That difference changes when the fallback gets computed.
Think of unwrap_or like writing a backup plan on a sticky note. You write the plan down right now, even if you never use it. The ink dries, the paper exists, and the effort is spent before you check if you need the plan.
unwrap_or_else is like calling a friend who makes the plan only when you actually need it. You pass the phone number, but the friend stays asleep until you dial. If the main plan works, the friend never wakes up. No effort is wasted.
This is lazy evaluation. The closure runs only if the Option is None or the Result is Err. If the value is present, the closure is dropped without execution.
Lazy evaluation saves cycles. Trust the closure to do the work only when needed.
Minimal example: The cost of eagerness
See the difference in action. This example shows how unwrap_or evaluates its argument immediately, even when the value is present.
fn main() {
let maybe_number: Option<i32> = Some(42);
// unwrap_or evaluates the default argument right now.
// expensive_default() runs even though we have Some(42).
let result = maybe_number.unwrap_or(expensive_default());
println!("Result: {}", result);
}
/// Simulates a costly operation to generate a fallback value.
fn expensive_default() -> i32 {
println!("Computing default...");
0
}
Run this code. The output includes "Computing default..." followed by "Result: 42". The default was computed, discarded, and the work was wasted. The compiler sees expensive_default() as a function call that must happen before unwrap_or receives its argument. Rust evaluates arguments eagerly.
Now switch to unwrap_or_else.
fn main() {
let maybe_number: Option<i32> = Some(42);
// unwrap_or_else takes a closure.
// The closure runs only if maybe_number is None.
let result = maybe_number.unwrap_or_else(|| expensive_default());
println!("Result: {}", result);
}
/// Simulates a costly operation to generate a fallback value.
fn expensive_default() -> i32 {
println!("Computing default...");
0
}
Run this version. The output is just "Result: 42". "Computing default..." never appears. The closure || expensive_default() was passed to unwrap_or_else, but since maybe_number is Some, the method returned 42 and never called the closure.
Don't pay for the default if you don't use it. Switch to unwrap_or_else.
How the compiler sees it
The compiler treats these methods differently based on their signatures.
unwrap_or has a signature like fn unwrap_or(self, default: T) -> T. The default parameter is a value of type T. To call the method, the compiler must produce that value first. If you write option.unwrap_or(func()), the compiler generates code to call func, store the result in a temporary, and then pass that temporary to unwrap_or. The call happens unconditionally.
unwrap_or_else has a signature like fn unwrap_or_else<F>(self, f: F) -> T where F: FnOnce() -> T. The parameter f is a closure. The compiler passes the closure by reference. Inside the method, the code checks the Option. If it's Some, it returns the value and drops the closure. If it's None, it invokes the closure to get the value. The invocation is conditional.
This distinction applies to Result as well, with one twist. On Result<T, E>, the closure for unwrap_or_else receives the error. The signature becomes F: FnOnce(E) -> T. The closure gets the error value so you can inspect it, log it, or transform it into a default.
The compiler optimizes based on what you write. Write lazy code, get lazy execution.
Realistic example: Config and errors
In real code, unwrap_or_else shines when you need to react to failure. You often want to log an error, exit the process, or compute a fallback based on the error details.
use std::fs;
use std::process;
/// Loads configuration from a file, or exits with a message if missing.
fn load_config() -> String {
fs::read_to_string("config.json")
.unwrap_or_else(|err| {
// This block runs only if read_to_string returns Err.
// We can access the error details here.
eprintln!("Failed to load config: {err}");
process::exit(1);
})
}
fn main() {
let config = load_config();
println!("Loaded config: {}", config);
}
The closure captures nothing and returns ! (the never type) because process::exit never returns. Rust's type system allows ! to coerce to any type, so the closure satisfies the requirement to return String. The code compiles and runs cleanly. If the file exists, the closure is never executed. If the file is missing, the error is printed and the process terminates.
Convention aside: unwrap_or_else(|e| panic!("message: {e}")) is the idiomatic way to panic with a custom message in Rust. It's concise, lazy, and includes the error context. You'll see this pattern in many codebases.
Keep error handling close to the failure. unwrap_or_else lets you react to the error without scattering logic.
The closure signature adapts
The closure signature changes depending on whether you're working with Option or Result. This is a deliberate design choice.
For Option<T>, there is no error information. The closure takes no arguments: FnOnce() -> T.
let value: Option<i32> = None;
let default = value.unwrap_or_else(|| {
// No arguments. Just compute the default.
42
});
For Result<T, E>, the error is available. The closure takes the error as an argument: FnOnce(E) -> T.
let result: Result<i32, String> = Err("bad input".to_string());
let value = result.unwrap_or_else(|err| {
// The error is passed in.
// You can log it or use it to decide the default.
eprintln!("Error: {err}");
0
});
If you try to use the wrong signature, the compiler rejects you with E0277 (trait bound not satisfied). The closure must match the expected function type. This forces you to acknowledge the error when it exists.
The API adapts to the type. Let the closure signature guide your error handling.
Pitfalls and compiler errors
Using these methods incorrectly leads to wasted work or type errors.
The most common mistake is using unwrap_or with a function call.
let value = option.unwrap_or(compute_default());
If compute_default is expensive, you're paying for it every time, even on success. The compiler won't warn you. This is a logic error, not a syntax error. The code compiles and runs, but it's slower than it needs to be. Switch to unwrap_or_else to fix this.
Another pitfall is type mismatch. The fallback must have the same type as the value inside the Option or Result.
let option: Option<i32> = Some(10);
let value = option.unwrap_or("default");
The compiler rejects this with E0308 (mismatched types). The Option holds i32, but the fallback is &str. Rust won't guess how to convert them. You must provide a fallback of the correct type.
let value = option.unwrap_or(0);
If you're working with Result, the closure must return the success type T, not the error type E.
let result: Result<i32, String> = Err("err".to_string());
let value = result.unwrap_or_else(|_| "fallback");
This also triggers E0308. The closure returns &str, but the Result expects i32. The closure must transform the error into a valid T.
The compiler enforces type safety here. Fix the type, don't force the value.
Decision: Choosing the right tool
Pick the method that matches your fallback's cost and complexity.
Use unwrap_or when the fallback is a literal, a constant, or a variable that is already in scope. Use unwrap_or when computing the default is negligible and you want concise code. Use unwrap_or_else when the fallback requires computation, I/O, or logging. Use unwrap_or_else when you need to inspect the error value to determine the fallback. Use unwrap_or_else when the default is expensive and you want to avoid the cost on the success path. Use match when you need to execute complex logic for both the success and failure branches. Reach for unwrap only in tests or throwaway code where failure is logically impossible.
Defaulting is a design choice. Make it explicit, make it lazy, and make it cheap.