Skip to content

Initial values: when Option is the wrong tool

Level: 201 · working knowledge

One line: For "a variable with no value yet", Rust usually does not want Option at all — it lets you declare without initializing and proves you assigned before use.

This is the one job on std::option's list ↗ where the obvious reading leads most newcomers astray, so it is worth separating the case Option genuinely serves from the case it only appears to.


The pattern you will write first

let mut initial_value: Option<i32> = None;
initial_value = Some(42);

match initial_value {
    Some(value) => println!("The initial value is: {value}"),
    None => println!("No initial value"),
}

This works. But count what it costs: a mut, a wrapper, and a match over a case that — by the time we look — cannot happen. The None arm is unreachable and the compiler cannot tell you so, because you asked for a type where absence is legal.

The habit comes from languages where a variable must be given something at declaration, so you reach for a "nothing yet" placeholder: null, None, -1, "". Rust does not have that constraint.

What Rust actually offers

let settled: i32;          // declared, not initialized — and not `mut`
if flag {
    settled = 42;
} else {
    settled = 7;
}
println!("{settled}");     // fine: every path assigned it exactly once

Rust does not require a value at declaration. It requires one before use, and it proves that at compile time. Miss a branch and you get error[E0381]: used binding is possibly-uninitialized; assign twice and the missing mut catches you. So settled is a plain i32 — no wrapper, no mut, no unreachable arm — and it is more strongly checked than the Option version, not less.

Most of the time you do not even need the statement form:

let same = if flag { 42 } else { 7 };

The test: if every path assigns before first use, the answer is deferred initialization. Option is for when that is not true.

When Option is genuinely right

The absence survives to the point of use. A setting nobody configured is still missing when you read it — None there is not "not assigned yet", it is "the user did not say", which is a fact about the world and belongs in the type:

let configured: Option<u16> = lookup("port").and_then(|s| s.parse().ok());
let port = configured.unwrap_or(8080);

There is no honest starting value. A running "best so far" has nothing sensible to start at — 0 is a lie for an empty list and wrong the moment negatives are allowed:

let mut best: Option<u32> = None;
for s in scores {
    best = Some(best.map_or(s, |b| b.max(s)));
}

This is why iter().max() returns Option rather than picking a sentinel, and in real code you would just call it. The pattern still matters for folds that have no library equivalent.

It is a struct field. A field cannot be deferred — a struct is either fully built or does not exist — so "may not be set yet" has to be Option in the type. That case has its own page.

It is a static. Same reason, and the modern tool is OnceLock:

static GREETING: OnceLock<String> = OnceLock::new();
GREETING.set("hello".to_string()).expect("first set always succeeds");

Prefer it over a mut Option in a global: it guarantees the value is written exactly once, and it is thread-safe, neither of which a bare Option promises.


Practice

Declare it, then prove it. Write sample_size_for(rows: usize) -> usize whose value is decided in three branches — no Option, and no mut. Let the compiler be the thing that guarantees the variable is set before it is read.

Write it with let mut sample: Option<usize> = None; too, and read what the compiler says about the None you gave it. Then delete one branch from the plain version and read E0381. One of those two messages is a warning about a value you never needed; the other is a proof you cannot fake.

Solution

initial_values_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: a value that is not ready yet is not the same as a missing one.
//!
//!   rustc --edition 2024 initial_values_kata.rs -o /tmp/ivk && /tmp/ivk

/// Declared, not initialized. Every path assigns before the read, and the
/// compiler checks that — no `Option`, no `mut`, no unreachable arm.
fn sample_size_for(rows: usize) -> usize {
    let sample: usize;
    if rows == 0 {
        sample = 0;
    } else if rows < 10 {
        sample = rows; // a tiny table: read all of it
    } else {
        sample = rows / 2 + 1;
    }
    sample
}

/// The `Option` version of the same job, for contrast: one more state to read,
/// one more way to be wrong, and an arm that cannot happen.
///
/// The allow is part of the lesson — without it the compiler warns that the
/// `None` is "never read", which is it telling you the initial value was dead
/// on arrival.
#[allow(unused_assignments)]
fn sample_size_awkward(rows: usize) -> usize {
    let mut sample: Option<usize> = None;
    if rows == 0 {
        sample = Some(0);
    } else if rows < 10 {
        sample = Some(rows);
    } else {
        sample = Some(rows / 2 + 1);
    }
    sample.expect("every branch above assigns — but nothing checks that claim")
}

/// Where `Option` is genuinely right: the value may never arrive at all.
struct Job {
    name: &'static str,
    /// None until the job finishes. Not "not ready yet" — "may never happen".
    finished_at: Option<&'static str>,
}

fn main() {
    println!("Declared without a value, proved assigned before use:");
    for rows in [0, 7, 461] {
        println!("  sample_size_for({rows:>3}) -> {}", sample_size_for(rows));
    }

    println!("\nThe Option version returns the same numbers…");
    for rows in [0, 7, 461] {
        println!("  sample_size_awkward({rows:>3}) -> {}", sample_size_awkward(rows));
    }
    println!("      …and pays for it with a state that cannot occur, an `expect`");
    println!("      whose claim nothing verifies, and a `mut` it did not need. The");
    println!("      compiler even warns that the initial None is never read — the");
    println!("      source has to #[allow] it to compile quietly.");

    println!("\nWhere Option earns its place — absence that outlives the function:");
    let running = Job { name: "nightly-reindex", finished_at: None };
    let done = Job { name: "nightly-backup", finished_at: Some("03:12") };
    for j in [&running, &done] {
        match j.finished_at {
            Some(at) => println!("  {:<18} finished at {at}", j.name),
            None => println!("  {:<18} still running", j.name),
        }
    }
}

Verified output of initial_values_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

Declared without a value, proved assigned before use:
  sample_size_for(  0) -> 0
  sample_size_for(  7) -> 7
  sample_size_for(461) -> 231

The Option version returns the same numbers…
  sample_size_awkward(  0) -> 0
  sample_size_awkward(  7) -> 7
  sample_size_awkward(461) -> 231
      …and pays for it with a state that cannot occur, an `expect`
      whose claim nothing verifies, and a `mut` it did not need. The
      compiler even warns that the initial None is never read — the
      source has to #[allow] it to compile quietly.

Where Option earns its place — absence that outlives the function:
  nightly-reindex    still running
  nightly-backup     finished at 03:12

The verified output

Verified output of initial_values.rs — regenerated by tools/run_examples.py, never hand-typed.

──── Step 1: The pattern you will see written first
  The initial value is: 42
      It works. But notice what it costs: a `mut`, a wrapper, and a `match`
      over a case that — by the time we look — cannot happen.
      rustc agrees, and says so: "value assigned to `initial_value` is
      never read" — the placeholder was dead the moment it was written.

──── Step 2: What Rust actually offers: declare now, assign later
  settled = 42
  same    = 42
      Rust does not require a value at DECLARATION — only before USE, and
      it proves that at compile time. So `settled` is never Option-shaped,
      never mut, and no branch can forget to set it.

──── Step 3: When Option IS right: absence survives to the point of use
  lookup("log_level")  -> Some("debug")
  lookup("port")       -> None
  port in use          -> 8080
      Here None is not 'not yet assigned', it is 'the user did not say'.
      That fact is still true when we read it — so it belongs in the type.

──── Step 4: …and for a running 'best so far', where there is no sensible start
  best of [3, 9, 4] -> Some(9)
  best of []        -> None
      Starting at 0 would be a LIE for an empty list, and wrong for negatives.
      (In real code: scores.iter().max() — which returns Option for this reason.)
  scores.iter().max() -> Some(9)

──── Step 5: Initialize once, later, globally: OnceLock
  before set: None
  after set:  Some("hello")
  second set: true
      A `static` cannot be deferred — so this is the case that genuinely
      needs an 'empty until later' box. OnceLock is that box, and unlike a
      mut Option it guarantees the value is written exactly once.

Run it yourself:

rustc --edition 2024 17_Option_and_Result/initial_values/examples/initial_values.rs -o /tmp/iv && /tmp/iv

See also

Po polsku

W większości języków, z których przychodzi się do Rusta, deklaracja zmiennej domaga się czegoś po prawej stronie — stąd odruch, żeby napisać let mut initial_value: Option<i32> = None;, a prawdziwą wartość wstawić dopiero za chwilę. W Ruscie ten odruch jest zbędny: deklaracja nie wymaga wartości, wymaga jej dopiero użycie, a to, czy każda gałąź przypisała coś przed odczytem, sprawdza kompilator. let settled: i32; — bez mut, po jednym przypisaniu na każdej gałęzi; jeśli o którejś zapomnisz, dostaniesz error[E0381]: used binding is possibly-uninitialized. To jest odroczona inicjalizacja (deferred initialization) i warto ją tak nazywać, żeby nie mylić jej z brakiem wartości.

Rachunek wypada wyraźnie na niekorzyść Option. Wersja z opakowaniem kosztuje mut, jeden typ więcej i ramię None w matchu, do którego nigdy się nie dochodzi, a expect po niej to obietnica, której nic nie weryfikuje. Wersja bez Option jest sprawdzona mocniej, nie słabiej: brak mut wyklucza drugie przypisanie, a E0381 wyklucza brak pierwszego. Kompilator zresztą sam to podpowiada — ostrzega, że przypisana na starcie wartość „is never read”, czyli że zastępcza wartość początkowa była martwa już w chwili zapisu. Test do zapamiętania jest jednozdaniowy: jeśli każda ścieżka przypisuje wartość przed pierwszym odczytem, to jest odroczona inicjalizacja, a nie brak wartości.

Option zarabia na siebie wtedy, gdy brak dożywa momentu odczytu. Nieustawiony parametr konfiguracji to nie „jeszcze nieprzypisane”, tylko „użytkownik nic nie podał” — fakt o świecie, więc jego miejsce jest w typie (configured.unwrap_or(8080)). Podobnie tam, gdzie nie ma uczciwej wartości początkowej: dla „najlepszego dotąd” zero jest kłamstwem przy pustej liście i błędem przy liczbach ujemnych — dlatego iter().max() zwraca Option, zamiast wybierać wartownika (sentinel value) w rodzaju -1 czy "". Zostają jeszcze dwa miejsca, w których odroczenie po prostu nie istnieje: pole struktury (struktura jest albo w pełni zbudowana, albo nie ma jej wcale) oraz static — a dla tego drugiego sięgaj po OnceLock, który gwarantuje zapis dokładnie raz i jest bezpieczny wątkowo, czego mut Option nie obiecuje.

Szukaj po polsku: odroczona inicjalizacja · zmienna niezainicjalizowana · wartość wartownicza · rust E0381 possibly-uninitialized · rust OnceLock static