Option in Rust and How to Stop Fearing the Null Case
Option replaces null in Rust. Learn Some and None, map, and_then, unwrap_or, the question mark operator and why unwrap on None panics.

Tony Hoare called the null reference his billion-dollar mistake: a value of any type might secretly be missing, and nothing in the type says so. Rust has no null. A value that may be absent has its own type, Option<T>, and the compiler makes you deal with the absent case before it lets you touch the value. This post covers how to read an Option, the methods that save you from match, and the two panics you should recognise. Everything ran on Rust 1.99, edition 2024.
The type, and the only way to look inside
fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("ada"),
2 => Some("grace"),
_ => None,
}
}
fn main() {
for id in [1, 2, 3] {
match find_user(id) {
Some(name) => println!("{id}: {name}"),
None => println!("{id}: no such user"),
}
}
}1: ada
2: grace
3: no such userOption<T> is an ordinary enum, defined in the standard library as Some(T) or None, as described in Rust enums. find_user returns Some(name) when it knows the id and None when it does not, and the caller has to say what happens in each case; the match is checked for exhaustiveness like any other. Because a &'static str and an Option<&'static str> are different types, a function that returns the first can never hand you nothing. The absence is visible in the signature.
That visibility is also why you cannot treat an Option as its inner value:
fn main() {
let a: Option<i32> = Some(1);
let b = a + 1;
println!("{b:?}");
}error[E0369]: cannot add `{integer}` to `Option<i32>`
--> src/main.rs:3:15
|
3 | let b = a + 1;
| - ^ - {integer}
| |
| Option<i32>
|
note: `Option<i32>` does not implement `Add<{integer}>`Option<i32> does not implement addition, because "maybe a number plus one" has no sensible answer. In another language a + 1 with a null a would crash somewhere later, or produce garbage. Here it is a build error, and the fix is to decide what absence means: a.map(|n| n + 1) keeps the absence, a.unwrap_or(0) + 1 replaces it with a default. The mismatch in the other direction is equally common:
fn first_even(nums: &[i32]) -> Option<i32> {
for &n in nums {
if n % 2 == 0 {
return n;
}
}
None
}
fn main() {
println!("{:?}", first_even(&[1, 3, 4]));
}error[E0308]: mismatched types
--> src/main.rs:4:20
|
1 | fn first_even(nums: &[i32]) -> Option<i32> {
| ----------- expected `Option<i32>` because of return type
...
4 | return n;
| ^ expected `Option<i32>`, found `i32`
|
= note: expected enum `Option<i32>`
found type `i32`
help: try wrapping the expression in `Some`
|
4 | return Some(n);The function promises an Option<i32>, and return n; returns a bare i32. The compiler suggests wrapping it in Some, which is the right answer: return Some(n);. The error is one of the family in E0308, and the "try wrapping" suggestion is worth trusting.
unwrap and expect: when None is a bug
unwrap returns the inside of a Some and panics on None:
fn find_user(id: u32) -> Option<&'static str> {
if id == 1 { Some("ada") } else { None }
}
fn main() {
let name = find_user(7).unwrap();
println!("{name}");
}thread 'main' panicked at src/main.rs:6:29:
called `Option::unwrap()` on a `None` valueThe message says what happened but not why it matters, and in a large program the line number is all you have. expect takes a message that says what you assumed:
fn main() {
let port: Option<u16> = std::env::var("APP_PORT").ok().and_then(|v| v.parse().ok());
let port = port.expect("APP_PORT must be set to a number");
println!("{port}");
}thread 'main' panicked at src/main.rs:3:21:
APP_PORT must be set to a numberPrefer expect("reason this cannot be None") to unwrap in any code that is not a test or a throwaway script: the message is the first thing you read at 3 a.m. Both are right when None means the program is broken, a configuration value that must exist or an invariant you just established. Neither is right for a value that comes from a user, a file or the network. A more complete treatment of the panic is in called Option::unwrap() on a None value.
Methods that replace match
A match with a Some arm that rewraps its result and a None arm that returns None is a map written by hand. The standard library has the common shapes ready:
use std::collections::HashMap;
fn main() {
let mut ages: HashMap<&str, u32> = HashMap::new();
ages.insert("ada", 36);
// map: transform the inside, leave None alone
let next_year = ages.get("ada").map(|age| age + 1);
println!("{next_year:?}");
// unwrap_or / unwrap_or_else: a default for None
let guest = ages.get("bob").copied().unwrap_or(0);
println!("{guest}");
// and_then: chain steps that can each fail
let digits: Option<u32> = Some("42").and_then(|s| s.parse().ok());
println!("{digits:?}");
// filter: keep Some only when the predicate holds
let adult = ages.get("ada").copied().filter(|&a| a >= 18);
println!("{adult:?}");
// ok_or: turn None into an error so the caller can use ?
let bob: Result<u32, String> = ages.get("bob").copied().ok_or("bob is unknown".to_string());
println!("{bob:?}");
// zip / or / xor
println!("{:?}", Some(1).zip(Some("a")));
println!("{:?}", None.or(Some(9)));
}Some(37)
0
Some(42)
Some(36)
Err("bob is unknown")
Some((1, "a"))
Some(9)maptransforms the inside and leavesNonealone: the age 36 becameSome(37).unwrap_or(default)supplies a fallback forNone; useunwrap_or_elsewith a closure when the default is expensive to build. The missing userbobbecame 0.and_thenchains a step that itself returns anOption. Here, parsing a string that might fail givesSome(42). This isflat_mapin other languages.filterkeeps aSomeonly when the predicate is true.ok_orconvertsNoneinto anErr, which is how an absent value becomes an error the caller can propagate with?. TheResultside of that is in Result explained.zippairs two options into one,Someonly if both are, andorsupplies another option when the first isNone.
The choice between match and these methods is about readability. One level of transformation reads better as map; three nested transformations with different fallbacks read better as a match with a named variable per step.
The question mark on Options
If a function itself returns Option, ? unwraps a Some and returns None from the function at the first None:
struct User {
name: &'static str,
manager: Option<u32>,
}
fn user(id: u32) -> Option<User> {
match id {
1 => Some(User { name: "ada", manager: Some(2) }),
2 => Some(User { name: "grace", manager: None }),
_ => None,
}
}
// Each `?` returns None from the function if the value is None.
fn managers_name(id: u32) -> Option<&'static str> {
let manager_id = user(id)?.manager?;
Some(user(manager_id)?.name)
}
fn main() {
println!("{:?}", managers_name(1));
println!("{:?}", managers_name(2));
println!("{:?}", managers_name(9));
}Some("grace")
None
Nonemanagers_name(1) finds user 1, then their manager id 2, then user 2's name, grace. For user 2 the first ? succeeds and the second, on manager, hits None and the function returns None immediately. For an unknown user, the very first ? returns. Compare that with the version that would nest three match expressions: the ? form reads as the happy path, with each potential absence a single character. The same operator works on Result in functions that return Result, covered in error handling, and you cannot mix the two in one function without converting with ok_or.
Borrowing and moving inside an Option
Options own their contents, so working with them hits the same ownership rules as anything else. Three methods deal with it:
fn main() {
let mut slot: Option<String> = Some("job-17".to_string());
// as_ref: look without moving
println!("{}", slot.as_ref().map(|s| s.len()).unwrap_or(0));
println!("{slot:?}");
// take: move the value out and leave None behind
let job = slot.take();
println!("{job:?} / {slot:?}");
// replace and get_or_insert_with
let cache: &mut String = slot.get_or_insert_with(|| "default".to_string());
cache.push_str("!");
println!("{slot:?}");
}6
Some("job-17")
Some("job-17") / None
Some("default!")as_ref() turns &Option<String> into Option<&String>, so you can look at the string (here, its length, 6) without moving it out. take() moves the value out and leaves None behind, which is how you pull a job out of a slot or an optional field out of a struct that you hold by &mut. get_or_insert_with fills the slot if it is empty and returns a mutable reference to what is inside, so the final line shows Some("default!") after the push. These matter most for struct fields of type Option<T>; ownership explains why you cannot simply move a field out of a borrowed struct.
It costs nothing extra
You might expect wrapping every optional value in an enum to be expensive. For many types it is free:
use std::mem::size_of;
fn main() {
println!("&u8: {}", size_of::<&u8>());
println!("Option<&u8>: {}", size_of::<Option<&u8>>());
println!("Box<u64>: {}", size_of::<Box<u64>>());
println!("Option<Box<u64>>: {}", size_of::<Option<Box<u64>>>());
println!("u32: {}", size_of::<u32>());
println!("Option<u32>: {}", size_of::<Option<u32>>());
println!("std::num::NonZeroU32: {}", size_of::<std::num::NonZeroU32>());
println!("Option<NonZeroU32>: {}", size_of::<Option<std::num::NonZeroU32>>());
}&u8: 8
Option<&u8>: 8
Box<u64>: 8
Option<Box<u64>>: 8
u32: 4
Option<u32>: 8
std::num::NonZeroU32: 4
Option<NonZeroU32>: 4A reference can never be null, so None can be represented as the all-zero pointer, and Option<&u8> is the same 8 bytes as &u8. The same goes for Box, and for NonZeroU32, a u32 that excludes zero: Option<NonZeroU32> is 4 bytes. A plain u32 uses all its bit patterns, so Option<u32> needs room for a tag and doubles to 8 bytes. This is the compiler's niche optimisation, and it is why Rust code can use Option freely without paying for it.
Habits
- Return
Optionfrom a function that can legitimately find nothing, andResultwhen the reason matters. - Handle
Noneat the boundary where you know what it means, and pass theOptionalong until then. - Use
expectwith a reason instead of bareunwrap. - Prefer
map,and_thenand?to amatchthat only rewraps. - Do not use
Option<bool>for three-state logic; a three-variant enum has names.
When Option is not the right type
If the absence has a cause the caller should know about (file not found, permission denied, parse failure), None throws that information away, and Result<T, E> is the right type. If a value is always present after construction but is set later, a builder or a state enum is cleaner than a field that is Option everywhere and unwrapped everywhere. And a Vec<T> already represents "zero or more", so a function returning Option<Vec<T>> where None and an empty vector mean the same thing is making callers handle two empty cases.


