The fallback trap
You're fetching a user's nickname from a cache. The lookup returns Option<String>. Most users have a nickname. Some don't. Your UI needs a String to display, not an Option. You reach for unwrap_or and pass a function call to generate a fallback. The app compiles. It runs. Then you notice the CPU usage spikes every time you render a list of users, even the ones who already have nicknames.
The bug isn't in the logic. It's in the evaluation order. You paid for the fallback computation on every single user, even when the value was present. Rust gives you three tools to handle missing values. They look similar, but they differ in when they compute the fallback. Picking the wrong one leaks performance or obscures intent.
Option, Result, and the need for a value
Rust uses Option<T> to represent a value that might be missing. Some(T) holds the value. None holds nothing. Result<T, E> does the same for errors: Ok(T) or Err(E).
Both types force you to handle the missing case. You can't accidentally use a None as a value. Eventually, you need a concrete T to pass to the rest of your code. That's where unwrap_or, unwrap_or_else, and unwrap_or_default come in. They consume the Option or Result and produce a T.
The distinction is lazy versus eager evaluation. Eager evaluation computes the fallback immediately, regardless of whether it's needed. Lazy evaluation defers the computation until the fallback is actually required. Rust exposes both strategies so you can choose the right cost model.
unwrap_or: The eager default
unwrap_or takes a value and returns that value if the Option is None. If the Option is Some, it returns the inner value and discards the argument you passed.
The critical detail is that Rust evaluates function arguments before calling the function. When you write opt.unwrap_or(fallback), the fallback expression runs first. If opt is Some, Rust computes fallback, throws it away, and returns the Some value.
fn main() {
let present: Option<i32> = Some(42);
// This prints "Computing..." even though present is Some.
// The argument is evaluated before unwrap_or runs.
let value = present.unwrap_or(compute_fallback());
println!("Result: {value}");
}
fn compute_fallback() -> i32 {
println!("Computing...");
0
}
Use unwrap_or when the fallback is a cheap literal, a constant, or a variable already in scope. The cost of computing the fallback is negligible, so the eager evaluation doesn't hurt.
let score: Option<i32> = None;
// 0 is a literal. No computation cost.
let final_score = score.unwrap_or(0);
Convention aside: Prefer unwrap_or for literals and simple variables. It reads cleanly and signals that the fallback is trivial. If you see unwrap_or with a function call, pause. That function runs unconditionally.
unwrap_or_else: The lazy closure
unwrap_or_else takes a closure. A closure is a chunk of code that runs later. Rust passes the closure to the method without executing it. The method calls the closure only if the value is missing.
This is lazy evaluation. The fallback computation happens zero times if the value is present. It happens exactly once if the value is missing.
fn main() {
let present: Option<i32> = Some(42);
// The closure runs only if present is None.
// Since present is Some, compute_fallback is never called.
let value = present.unwrap_or_else(|| compute_fallback());
println!("Result: {value}");
}
fn compute_fallback() -> i32 {
println!("Computing...");
0
}
Use unwrap_or_else when the fallback requires work. Database queries, file reads, complex calculations, and I/O operations belong in the closure. You avoid paying for work you don't need.
unwrap_or_else also lets you perform side effects. You can log a warning, insert a value into a cache, or panic with a custom message. The closure runs in the context of the missing value, giving you full control over the recovery path.
use std::collections::HashMap;
fn get_or_insert(cache: &mut HashMap<String, String>, key: &str) -> String {
cache
.get(key)
.cloned()
// If missing, compute, insert, and return.
.unwrap_or_else(|| {
let value = format!("computed-{}", key);
cache.insert(key.to_string(), value.clone());
value
})
}
Convention aside: unwrap_or_else is the idiomatic way to panic with error context. When working with Result, unwrap_or_else(|e| panic!("Failed: {e}")) is standard. It turns an error into a panic with a helpful message, and it keeps the panic logic localized.
unwrap_or_default: The semantic zero
unwrap_or_default returns the default value for the type if the Option is None. It works only for types that implement the Default trait.
Under the hood, unwrap_or_default is implemented as unwrap_or_else(Default::default). It is lazy. It calls Default::default() only when needed.
fn main() {
let missing: Option<String> = None;
// Returns an empty String, which is the Default for String.
let name = missing.unwrap_or_default();
assert_eq!(name, "");
}
Many standard types implement Default. String defaults to "". Vec<T> defaults to []. HashMap defaults to {}. Numeric types like i32 and f64 default to 0. Booleans default to false.
You can derive Default for your own structs. This unlocks unwrap_or_default for custom types.
#[derive(Default)]
struct Config {
timeout: u32,
retries: u32,
}
fn main() {
let config: Option<Config> = None;
// Uses the derived Default impl.
let fallback = config.unwrap_or_default();
assert_eq!(fallback.timeout, 0);
}
Convention aside: Use unwrap_or_default instead of unwrap_or(T::default()). Both work, but unwrap_or_default is shorter and signals intent. It tells the reader that the fallback is the type's natural zero state, not an arbitrary value.
The cost of eagerness in action
The difference between eager and lazy evaluation becomes visible in benchmarks and logs. Consider a function that parses a configuration string. Parsing takes time.
fn parse_config(input: &str) -> Result<Config, String> {
// Simulate expensive parsing.
std::thread::sleep(std::time::Duration::from_millis(100));
Ok(Config { query: input.to_string() })
}
struct Config {
query: String,
}
fn main() {
let input: Option<String> = Some("user-query".to_string());
// Eager: parse_config runs even though input is present.
// This wastes 100ms.
let _config = input.as_deref().map(|s| s.to_string())
.unwrap_or_else(|| "default".to_string());
// If you used unwrap_or with a function call, the function runs.
// Let's demonstrate the trap.
let _trap = input.as_deref().map(|s| s.to_string())
.unwrap_or(parse_config("default").map(|c| c.query).unwrap_or_else(|_| "err".to_string()));
}
The trap example above is contrived to show the pattern. In real code, you might write opt.unwrap_or(expensive_func()). If opt is Some, expensive_func runs and its result is dropped. The CPU cycles are gone. The memory allocation is gone. The I/O happened for nothing.
Lazy evaluation prevents this. unwrap_or_else ensures the fallback runs only when the primary value is absent. This is the zero-cost abstraction principle in action. You pay for the closure overhead only when you actually need the fallback. The overhead is negligible compared to the cost of the fallback computation.
Real-world patterns
Configuration loading often uses unwrap_or_else to handle errors gracefully. You try to load a config file. If it fails, you log the error and exit, or you fall back to hardcoded defaults.
use std::process;
fn load_config() -> Config {
Config::from_file("config.json")
.unwrap_or_else(|err| {
eprintln!("Failed to load config: {err}");
eprintln!("Using default configuration.");
Config::default()
})
}
This pattern combines error handling, logging, and fallback logic in one expression. The closure captures the error, prints it, and returns a default. If the file loads successfully, the closure never runs. No logging. No default construction.
Cache lookups benefit from unwrap_or_else for side effects. You check the cache. If the value is missing, you compute it, store it, and return it.
use std::collections::HashMap;
struct Cache {
store: HashMap<String, Vec<u8>>,
}
impl Cache {
fn get_or_fetch(&mut self, url: &str) -> Vec<u8> {
self.store
.get(url)
.cloned()
.unwrap_or_else(|| {
let data = self.fetch_from_network(url);
self.store.insert(url.to_string(), data.clone());
data
})
}
fn fetch_from_network(&self, _url: &str) -> Vec<u8> {
// Network call simulation.
vec![0u8; 1024]
}
}
The closure handles the missing case by fetching and inserting. The cloned() call extracts the value from the borrow. If the value is present, the closure is skipped. The network call never happens.
Pitfalls and compiler errors
Type mismatches are the most common error. unwrap_or and unwrap_or_else require the fallback to match the inner type exactly. If you have Option<i32>, the fallback must be i32.
let opt: Option<i32> = None;
// Error: mismatched types.
// Expected i32, found &str.
let _val = opt.unwrap_or("default");
The compiler rejects this with E0308 (mismatched types). The error message points to the fallback value and shows the expected type. Fix the type by converting the fallback or changing the Option type.
unwrap_or_default fails if the type doesn't implement Default.
struct NoDefault {
value: i32,
}
let opt: Option<NoDefault> = None;
// Error: the trait Default is not implemented for NoDefault.
let _val = opt.unwrap_or_default();
The compiler reports E0277 (trait bound not satisfied) or a message about Default not being implemented. Derive Default for the struct to fix this.
#[derive(Default)]
struct NoDefault {
value: i32,
}
Another pitfall is borrowing inside unwrap_or_else. The closure captures the environment. If you try to mutate a variable that's borrowed elsewhere, the borrow checker intervenes.
let mut cache = HashMap::new();
let key = "test";
// Error: cannot borrow cache as mutable more than once.
// The get call borrows cache immutably.
// The closure tries to borrow cache mutably.
let _val = cache.get(key)
.unwrap_or_else(|| {
cache.insert(key.to_string(), "value".to_string());
"value".to_string()
});
This triggers E0502 (cannot borrow as mutable because it is also borrowed as immutable). The get call holds an immutable borrow. The closure attempts a mutable borrow. The solution is to restructure the code to avoid overlapping borrows. Use entry API or separate the lookup and insertion.
Decision matrix
Use unwrap_or when the fallback is a literal, a constant, or a variable already in scope. Use unwrap_or when the fallback computation is negligible and you want concise code. Use unwrap_or_else when the fallback requires a function call, I/O, or complex logic. Use unwrap_or_else when you need to perform side effects like logging, caching, or error reporting. Use unwrap_or_else when you want to panic with a custom message based on the error. Use unwrap_or_default when the type implements Default and the default value is the logical fallback. Use unwrap_or_default for collections like Vec, String, and HashMap where an empty container is the safe zero-state.
Don't fight the compiler here. Reach for the lazy closure when the fallback costs cycles. Trust Default. It's the language's way of saying this is the safe empty state.