Guide contents

Lesson 22 of 94

Option: Replacing null

Tony Hoare, who invented the null reference, called it his "billion-dollar mistake": the type system does not tell you which references might be null, and you find out with a crash at runtime.

Rust has no null. A value that might be absent has the type Option<T>, an ordinary enum from the standard library:

Rust
enum Option<T> {
    None,    // no value
    Some(T), // a value of type T
}

The compiler will not let you use an Option<T> as if it were a T. You must handle None first. This is what makes null-dereference errors impossible.

Option<T> uses generics: T stands for any type, so there is Option<i32>, Option<String>, and so on. You only need to use generic types here; Generics shows how to write your own.

Concept Rust
A missing value let name: Option<String> = None;
Checking for a value if let Some(name) = name { ... }
Transforming with a default x.map(f).unwrap_or(d)
Forcing the value out option.unwrap() (panics if None)
Turning absence into an error option.ok_or(X)?
Which values can be absent Only an Option<T> can be None

1. Creating Some and None

Some and None are so common that they can be used without the Option:: prefix.

Rust
fn main() {
    let middle_name: Option<&str> = Some("Brewster");
    let no_middle_name: Option<&str> = None;
    let count = Some(3); // Option<i32>, inferred
    println!("{:?} {:?} {:?}", middle_name, no_middle_name, count);
}
Output
Some("Brewster") None Some(3)

None alone does not say what type is missing, so the compiler needs an annotation, or context, to know whether it is an Option<&str> or an Option<i32>.

2. An Option<i32> is not an i32

This is the whole point. You cannot accidentally treat a maybe-missing value as present.

Rust✗ does not compile · E0369
fn main() {
    let discount: Option<i32> = Some(10);
    let doubled = discount * 2;
    println!("{doubled}");
}
Output
error[E0369]: cannot multiply `Option<i32>` by `{integer}`
 --> src/main.rs:3:28
  |
3 |     let doubled = discount * 2;
  |                   -------- ^ - {integer}
  |                   |
  |                   Option<i32>
  |
note: `Option<i32>` does not implement `Mul<{integer}>`
 --> /rustc/<version>/library/core/src/option.rs:600:0
  |
  = note: `Option<i32>` is defined in another crate

3. Handling both cases with match

Rust
fn describe(discount: Option<i32>) -> String {
    match discount {
        Some(percent) => format!("{percent}% off"),
        None => String::from("full price"),
    }
}

fn main() {
    println!("{}", describe(Some(15)));
    println!("{}", describe(None));
}
Output
15% off
full price

4. A function that might not find anything

Return Option from any function that can come back empty-handed. The signature documents it, and every caller is forced to deal with it.

Rust
#[derive(Debug)]
struct User {
    id: u32,
    name: String,
}

fn find_user(users: &[User], id: u32) -> Option<&User> {
    for u in users {
        if u.id == id {
            return Some(u);
        }
    }
    None
}

fn main() {
    let users = vec![User { id: 1, name: String::from("Ana") }, User { id: 2, name: String::from("Ben") }];
    match find_user(&users, 2) {
        Some(user) => println!("found {}", user.name),
        None => println!("no such user"),
    }
    println!("{:?}", find_user(&users, 99));
}
Output
found Ben
None

5. The standard library returns Option everywhere

Any operation that might not produce a value returns an Option.

Rust
fn main() {
    let scores = vec![72, 95, 88];
    let empty: Vec<i32> = Vec::new();
    println!("get(1): {:?}", scores.get(1));
    println!("get(10): {:?}", scores.get(10));
    println!("first: {:?}", scores.first());
    println!("last of empty: {:?}", empty.last());
    println!("max: {:?}", scores.iter().max());
    println!("find '@': {:?}", "user@example.com".find('@'));
    println!("char at 20: {:?}", "short".chars().nth(20));
    println!("parse ok: {:?}", "42".parse::<i32>().ok());
    println!("parse bad: {:?}", "4x2".parse::<i32>().ok());
}
Output
get(1): Some(95)
get(10): None
first: Some(72)
last of empty: None
max: Some(95)
find '@': Some(4)
char at 20: None
parse ok: Some(42)
parse bad: None

.ok() converts a Result into an Option, discarding the error details.

6. if let when you only care about Some

Rust
fn main() {
    let cached_token: Option<&str> = Some("eyJhbGciOi");
    if let Some(token) = cached_token {
        println!("using cached token {token}...");
    }

    let nickname: Option<&str> = None;
    if let Some(n) = nickname {
        println!("hi {n}");
    } else {
        println!("hi there");
    }
}
Output
using cached token eyJhbGciOi...
hi there

7. let ... else for early returns

let Some(x) = value else { ... }; binds x when the value is Some. Otherwise it runs the else block, which must leave the function (with return, break, continue, or a panic). This keeps the happy path unindented.

Rust
fn shipping_label(name: Option<&str>, address: Option<&str>) -> String {
    let Some(name) = name else {
        return String::from("ERROR: missing name");
    };
    let Some(address) = address else {
        return format!("ERROR: missing address for {name}");
    };
    format!("{name}\n{address}")
}

fn main() {
    println!("{}", shipping_label(Some("Ada Lovelace"), Some("12 St James's Sq, London")));
    println!("{}", shipping_label(Some("Grace"), None));
    println!("{}", shipping_label(None, None));
}
Output
Ada Lovelace
12 St James's Sq, London
ERROR: missing address for Grace
ERROR: missing name

8. unwrap panics on None

unwrap() returns the value inside Some, and panics on None. Use it only in examples, tests, or when you have proved the value is present.

Rustpanics at runtime
fn main() {
    let items: Vec<&str> = Vec::new();
    let first = items.first().unwrap();
    println!("{first}");
}
Output
thread 'main' panicked at src/main.rs:3:31:
called `Option::unwrap()` on a `None` value

9. expect documents why it cannot fail

expect("...") is unwrap with a message. When you are sure a value is present, write down why in the message, so if you were wrong the panic explains itself.

Rustpanics at runtime
fn main() {
    let config_lines = vec!["port=8080"];
    let port_line = config_lines
        .iter()
        .find(|l| l.starts_with("host="))
        .expect("config must define a host");
    println!("{port_line}");
}
Output
thread 'main' panicked at src/main.rs:6:10:
config must define a host

10. Default values: unwrap_or, unwrap_or_default, unwrap_or_else

Rust
fn expensive_default() -> u32 {
    println!("  (computing default...)");
    8080
}

fn main() {
    let configured: Option<u32> = None;
    let present: Option<u32> = Some(3000);

    println!("{}", configured.unwrap_or(80));
    println!("{}", configured.unwrap_or_default()); // 0 for numbers
    println!("{}", configured.unwrap_or_else(expensive_default));
    println!("{}", present.unwrap_or_else(expensive_default)); // not called
}
Output
80
0
  (computing default...)
8080
3000

unwrap_or_else takes a function and only calls it when needed. Use it when the default is expensive to compute.

11. map transforms the value inside

map applies a function to the value if there is one, and leaves None alone. The function here is a closure, Rust's lambda: |x| x * 2. Closures covers them fully.

Rust
fn main() {
    let price_cents: Option<u64> = Some(1_999);
    let missing: Option<u64> = None;

    let formatted = price_cents.map(|c| format!("${}.{:02}", c / 100, c % 100));
    println!("{:?}", formatted);
    println!("{:?}", missing.map(|c| c * 2));

    let name: Option<&str> = Some("ferris");
    let length = name.map(|n| n.len());
    println!("{:?}", length);
}
Output
Some("$19.99")
None
Some(6)

12. and_then chains steps that can each fail

When the function you want to apply itself returns an Option, map would give you an Option<Option<T>>. and_then flattens it.

Rust
fn parse_port(s: &str) -> Option<u16> {
    s.parse().ok()
}

fn non_privileged(port: u16) -> Option<u16> {
    if port >= 1024 { Some(port) } else { None }
}

fn main() {
    for input in ["8080", "80", "http"] {
        let port = Some(input).and_then(parse_port).and_then(non_privileged);
        println!("{input:>5} -> {:?}", port);
    }
}
Output
 8080 -> Some(8080)
   80 -> None
 http -> None

13. filter keeps the value only if it passes a test

Rust
fn main() {
    let age: Option<u32> = Some(15);
    let adult = age.filter(|&a| a >= 18);
    println!("{:?} -> adult: {:?}", age, adult);

    let username: Option<&str> = Some("  ");
    let non_blank = username.filter(|u| !u.trim().is_empty());
    println!("{:?}", non_blank);
}
Output
Some(15) -> adult: None
None

14. ? returns early on None

Inside a function that returns Option, writing ? after an Option means: "if this is None, return None from the whole function right now; otherwise give me the value." It removes most match boilerplate.

Rust
fn domain_of(email: &str) -> Option<&str> {
    let at = email.find('@')?;           // None if there is no '@'
    let domain = &email[at + 1..];
    let dot = domain.find('.')?;         // None if the domain has no '.'
    if dot == 0 {
        return None;
    }
    Some(domain)
}

fn main() {
    println!("{:?}", domain_of("ana@example.com"));
    println!("{:?}", domain_of("no-at-sign"));
    println!("{:?}", domain_of("ben@localhost"));
    println!("{:?}", domain_of("cy@.com"));
}
Output
Some("example.com")
None
None
None

? also works with Result, where it is even more important; see Result & the ? Operator.

15. Checking without extracting

Rust
fn main() {
    let session: Option<u64> = Some(42);
    let guest: Option<u64> = None;
    println!("{} {}", session.is_some(), session.is_none());
    println!("{} {}", guest.is_some(), guest.is_none());
    println!("{}", session.is_some_and(|id| id > 40));
    println!("{}", guest.is_none_or(|id| id > 40));
}
Output
true false
false true
true
true

16. Borrowing the inside: as_ref and as_deref

Calling map on an Option<String> would move the String out. To just look inside, turn &Option<String> into Option<&String> with as_ref(), or straight into Option<&str> with as_deref().

Rust
struct Profile {
    display_name: Option<String>,
}

fn main() {
    let p = Profile { display_name: Some(String::from("Ferris the Crab")) };

    let length = p.display_name.as_ref().map(|n| n.len());
    let as_str: Option<&str> = p.display_name.as_deref();
    let shown = p.display_name.as_deref().unwrap_or("anonymous");

    println!("{:?} {:?} {shown}", length, as_str);
    println!("still owned by the profile: {:?}", p.display_name);
}
Output
Some(15) Some("Ferris the Crab") Ferris the Crab
still owned by the profile: Some("Ferris the Crab")

17. take moves the value out and leaves None

take() is std::mem::take for Option: it gives you the current value and leaves None behind. It is perfect for "use this once" values.

Rust
struct Job {
    on_complete: Option<String>, // a message to send once
}

impl Job {
    fn finish(&mut self) {
        if let Some(message) = self.on_complete.take() {
            println!("notify: {message}");
        } else {
            println!("already notified");
        }
    }
}

fn main() {
    let mut job = Job { on_complete: Some(String::from("backup finished")) };
    job.finish();
    job.finish();
}
Output
notify: backup finished
already notified

18. replace, insert, and get_or_insert_with

Rust
fn main() {
    let mut current_user: Option<String> = Some(String::from("ana"));
    let previous = current_user.replace(String::from("ben"));
    println!("switched from {:?} to {:?}", previous, current_user);

    let mut cache: Option<Vec<u64>> = None;
    let primes = cache.get_or_insert_with(|| {
        println!("  computing primes once...");
        vec![2, 3, 5, 7, 11]
    });
    println!("{:?}", primes);
    let again = cache.get_or_insert_with(|| vec![]);
    println!("{:?}", again);
}
Output
switched from Some("ana") to Some("ben")
  computing primes once...
[2, 3, 5, 7, 11]
[2, 3, 5, 7, 11]

19. Combining options: or, or_else, zip, xor

Rust
fn main() {
    let from_env: Option<&str> = None;
    let from_file: Option<&str> = Some("config.toml value");
    let chosen = from_env.or(from_file).unwrap_or("built-in default");
    println!("{chosen}");

    let width = Some(1920);
    let height = Some(1080);
    let missing: Option<i32> = None;
    println!("{:?}", width.zip(height));
    println!("{:?}", width.zip(missing));

    println!("{:?}", Some(1).xor(None::<i32>));
    println!("{:?}", Some(1).xor(Some(2)));
}
Output
config.toml value
Some((1920, 1080))
None
Some(1)
None

20. Optional struct fields

This fixes the "empty string means unchanged" hack from Structs. None means "not provided", and the type makes that explicit.

Rust
#[derive(Debug)]
struct Profile {
    display_name: String,
    website: Option<String>,
    newsletter: bool,
}

#[derive(Debug, Default)]
struct ProfileUpdate {
    display_name: Option<String>,
    website: Option<Option<String>>, // Some(None) means "remove the website"
    newsletter: Option<bool>,
}

impl Profile {
    fn apply(&mut self, update: ProfileUpdate) {
        if let Some(name) = update.display_name {
            self.display_name = name;
        }
        if let Some(website) = update.website {
            self.website = website;
        }
        if let Some(flag) = update.newsletter {
            self.newsletter = flag;
        }
    }
}

fn main() {
    let mut p = Profile {
        display_name: String::from("ferris"),
        website: Some(String::from("https://rust-lang.org")),
        newsletter: false,
    };
    p.apply(ProfileUpdate { newsletter: Some(true), ..Default::default() });
    println!("{:?}", p);
    p.apply(ProfileUpdate { website: Some(None), display_name: Some(String::from("Ferris")), ..Default::default() });
    println!("{:?}", p);
}
Output
Profile { display_name: "ferris", website: Some("https://rust-lang.org"), newsletter: true }
Profile { display_name: "Ferris", website: None, newsletter: true }

Option<Option<String>> distinguishes three cases that JSON PATCH APIs need: "leave it" (None), "clear it" (Some(None)), and "set it" (Some(Some(url))).

21. None costs nothing for references and boxes

A reference can never be null, so Rust uses the all-zero bit pattern to represent None. Option<&T> is the same size as &T: safety without any memory cost. This is called the null pointer optimization.

Rust
fn main() {
    println!("&u64:              {} bytes", std::mem::size_of::<&u64>());
    println!("Option<&u64>:      {} bytes", std::mem::size_of::<Option<&u64>>());
    println!("Box<u64>:          {} bytes", std::mem::size_of::<Box<u64>>());
    println!("Option<Box<u64>>:  {} bytes", std::mem::size_of::<Option<Box<u64>>>());
    println!("u64:               {} bytes", std::mem::size_of::<u64>());
    println!("Option<u64>:       {} bytes", std::mem::size_of::<Option<u64>>());
}
Output
&u64:              8 bytes
Option<&u64>:      8 bytes
Box<u64>:          8 bytes
Option<Box<u64>>:  8 bytes
u64:               8 bytes
Option<u64>:       16 bytes

Option<u64> does need a tag, because every bit pattern of a u64 is a valid number.

22. Comparing options

Option values can be compared with ==. For ordering, None is less than any Some.

Rust
fn main() {
    println!("{}", Some(3) == Some(3));
    println!("{}", Some(3) == None);
    println!("{}", None::<i32> < Some(i32::MIN));
    let mut deadlines = vec![Some(30), None, Some(7), None, Some(14)];
    deadlines.sort();
    println!("{:?}", deadlines);
}
Output
true
false
true
[None, None, Some(7), Some(14), Some(30)]

23. A for loop over an Option

An Option behaves like a collection of zero or one elements. This is handy for adding an optional item to a list.

Rust
fn main() {
    let cc: Option<&str> = Some("manager@example.com");
    let bcc: Option<&str> = None;
    let mut recipients = vec!["team@example.com"];
    for extra in cc {
        recipients.push(extra);
    }
    recipients.extend(bcc);
    println!("{:?}", recipients);
}
Output
["team@example.com", "manager@example.com"]

24. Collecting Options from a list

flatten() drops the Nones from a sequence of options. collect() into an Option<Vec<T>> gives None if any element was None, which is perfect for "all or nothing" parsing.

Rust
fn main() {
    let inputs = ["10", "x", "30"];

    let valid: Vec<i32> = inputs.iter().map(|s| s.parse().ok()).flatten().collect();
    println!("valid ones: {:?}", valid);

    let all: Option<Vec<i32>> = inputs.iter().map(|s| s.parse().ok()).collect();
    println!("all or nothing: {:?}", all);

    let good = ["1", "2", "3"];
    let all_good: Option<Vec<i32>> = good.iter().map(|s| s.parse().ok()).collect();
    println!("all or nothing: {:?}", all_good);
}
Output
valid ones: [10, 30]
all or nothing: None
all or nothing: Some([1, 2, 3])

25. Realistic example: configuration with layered fallbacks

Look up a setting in the command line, then the environment, then the config file, then fall back to a default. Each source may not have it.

Rust
fn lookup<'a>(pairs: &[(&str, &'a str)], key: &str) -> Option<&'a str> {
    for (k, v) in pairs {
        if *k == key {
            return Some(v);
        }
    }
    None
}

fn setting<'a>(key: &str, cli: &[(&str, &'a str)], env: &[(&str, &'a str)], file: &[(&str, &'a str)], default: &'a str) -> &'a str {
    lookup(cli, key)
        .or_else(|| lookup(env, key))
        .or_else(|| lookup(file, key))
        .unwrap_or(default)
}

fn main() {
    let cli = [("log_level", "trace")];
    let env = [("db_url", "postgres://prod"), ("log_level", "warn")];
    let file = [("db_url", "postgres://localhost"), ("workers", "8")];

    for key in ["log_level", "db_url", "workers", "timeout"] {
        println!("{key:>9} = {}", setting(key, &cli, &env, &file, "(default)"));
    }
}
Output
log_level = trace
   db_url = postgres://prod
  workers = 8
  timeout = (default)

The 'a lifetime annotations say the returned &str comes from the lookup tables (or the default), not from key. The Lifetimes section explains them.

26. Realistic example: parsing a time of day

Each step can fail, and ? keeps the code as short as the happy path.

Rust
fn minutes_since_midnight(time: &str) -> Option<u32> {
    let (h, m) = time.trim().split_once(':')?;
    let hours: u32 = h.parse().ok()?;
    let minutes: u32 = m.parse().ok()?;
    if hours > 23 || minutes > 59 {
        return None;
    }
    Some(hours * 60 + minutes)
}

fn main() {
    for t in ["09:30", "23:59", "24:00", "7:5", "noon", "12-00"] {
        println!("{t:>6} -> {:?}", minutes_since_midnight(t));
    }
}
Output
 09:30 -> Some(570)
 23:59 -> Some(1439)
 24:00 -> None
   7:5 -> Some(425)
  noon -> None
 12-00 -> None

27. Realistic example: walking a management chain

Each employee may have a manager. Following Option links until None finds the top of the organization.

Rust
struct Employee {
    id: u32,
    name: &'static str,
    manager_id: Option<u32>,
}

fn find(staff: &[Employee], id: u32) -> Option<&Employee> {
    staff.iter().find(|e| e.id == id)
}

fn chain_of_command(staff: &[Employee], id: u32) -> Vec<&'static str> {
    let mut chain = Vec::new();
    let mut current = find(staff, id);
    while let Some(e) = current {
        chain.push(e.name);
        current = e.manager_id.and_then(|m| find(staff, m));
    }
    chain
}

fn main() {
    let staff = [
        Employee { id: 1, name: "CEO Carol", manager_id: None },
        Employee { id: 2, name: "VP Victor", manager_id: Some(1) },
        Employee { id: 3, name: "Lead Lina", manager_id: Some(2) },
        Employee { id: 4, name: "Dev Dan", manager_id: Some(3) },
    ];
    println!("{}", chain_of_command(&staff, 4).join(" -> "));
    println!("{:?}", chain_of_command(&staff, 99));
}
Output
Dev Dan -> Lead Lina -> VP Victor -> CEO Carol
[]

while let Some(e) = current loops as long as there is a current employee. staff.iter().find(...) returns the first element matching a closure, as an Option.

28. Realistic example: statistics that may not exist

The average of an empty list does not exist. Returning Option<f64> is more honest than returning 0.0 or NaN.

Rust
fn average(values: &[f64]) -> Option<f64> {
    if values.is_empty() {
        return None;
    }
    let sum: f64 = values.iter().sum();
    Some(sum / values.len() as f64)
}

fn median(values: &[f64]) -> Option<f64> {
    if values.is_empty() {
        return None;
    }
    let mut sorted = values.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let mid = sorted.len() / 2;
    Some(if sorted.len() % 2 == 0 { (sorted[mid - 1] + sorted[mid]) / 2.0 } else { sorted[mid] })
}

fn main() {
    let latencies = [120.0, 80.0, 310.0, 95.0];
    let none: [f64; 0] = [];
    println!("avg {:?}, median {:?}", average(&latencies), median(&latencies));
    println!("avg {:?}, median {:?}", average(&none), median(&none));
    let report = average(&none).map(|a| format!("{a:.1} ms")).unwrap_or_else(|| String::from("no data"));
    println!("{report}");
}
Output
avg Some(151.25), median Some(107.5)
avg None, median None
no data

partial_cmp compares floats, which cannot be sorted with plain sort() because of NaN; Deriving Standard Traits explains why.

29. Realistic example: a lazily loaded resource

An Option field starts as None and is filled on first use. Later uses reuse it.

Rust
struct ReportService {
    template: Option<String>,
    loads: u32,
}

impl ReportService {
    fn template(&mut self) -> &str {
        if self.template.is_none() {
            self.loads += 1;
            self.template = Some(String::from("Report for {name}: {total}"));
        }
        self.template.as_deref().unwrap()
    }

    fn render(&mut self, name: &str, total: u32) -> String {
        self.template().replace("{name}", name).replace("{total}", &total.to_string())
    }
}

fn main() {
    let mut service = ReportService { template: None, loads: 0 };
    println!("{}", service.render("north", 120));
    println!("{}", service.render("south", 95));
    println!("template loaded {} time(s)", service.loads);
}
Output
Report for north: 120
Report for south: 95
template loaded 1 time(s)

The unwrap() is safe here: the line above guarantees the field is Some. get_or_insert_with from example 18 expresses the same thing in one call.

30. Realistic example: finding the next free slot

Rust
fn next_free_slot(booked: &[bool], preferred: usize) -> Option<usize> {
    if booked.is_empty() {
        return None;
    }
    let start = preferred.min(booked.len() - 1);
    for offset in 0..booked.len() {
        let slot = (start + offset) % booked.len();
        if !booked[slot] {
            return Some(slot);
        }
    }
    None
}

fn main() {
    let rooms = [true, false, true, true, false];
    println!("{:?}", next_free_slot(&rooms, 2));
    println!("{:?}", next_free_slot(&rooms, 0));
    println!("{:?}", next_free_slot(&[true, true], 1));
    println!("{:?}", next_free_slot(&[], 0));
}
Output
Some(4)
Some(1)
None
None

Summary

  • Option<T> is either Some(value) or None. There is no null, and an Option<T> cannot be used as a T by accident.
  • Handle it with match, if let, let ... else, or ? (in functions that return Option).
  • Avoid unwrap in real code; prefer expect with a reason, unwrap_or, unwrap_or_else, or unwrap_or_default.
  • Transform with map, and_then, and filter; combine with or, or_else, and zip.
  • Use as_ref and as_deref to look inside without moving, and take and replace to move values out of &mut Option.
  • Option<&T> and Option<Box<T>> cost no extra memory.

Next: Pattern Matching.