Variables and Mutability

Uninitialized Variables

An uninitialized variable is a variable declared with let that has not yet been assigned an initial value (let x; or let x: i32;). Unlike C and C++—where reading an uninitialized local stack variable silently reads garbage bytes and causes undefined behavior—Rust allows declaring a variable before initializing it, but statically forbids reading the variable until the compiler proves it has been initialized on every possible control-flow path.

Delayed Initialization Syntax

You can declare a variable with let name; (or let name: Type;) and assign its initial value later in the function:

fn main() {
    let status: &str; // Declared here without an initializer
    let code = 200;

    if code == 200 {
        status = "OK"; // Initialized on the `true` branch
    } else {
        status = "Error"; // Initialized on the `false` branch
    }

    // Allowed! `rustc` proves `status` is initialized on EVERY path before this line.
    println!("Status: {status}");
}
Status: OK

Notice two important rules in the example above:

  1. No mut is needed for delayed initialization: Even though status = "OK"; happens on a separate line after let status: &str;, status is an immutable binding because this is its first initialization, not a reassignment!
  2. Type inference still works: Even if you write let status; without : &str, rustc infers status as &str from the subsequent assignments status = "OK"; and status = "Error";.
let status: &str;           <-- State: Uninitialized (reading `status` here is a compile error)
       |
   +---+---+
   |       |
   v       v
"OK"    "Error"             <-- Initialized exactly once on both branches
   |       |
   +---+---+
       |
       v
println!("{status}");       <-- State: Definitely Initialized (safe to read, and immutable!)

Note: While delayed initialization is useful when setup logic requires intermediate steps inside branches or loops, in most cases using if or match directly as an expression (let status = if code == 200 { "OK" } else { "Error" };) is cleaner and avoids leaving the variable in an uninitialized state for any lines of code.

Definite Initialization Analysis (E0381)

The compiler's MIR control-flow analyzer tracks the initialization state of every local variable across every basic block. If there is even one possible execution path where a variable might reach a read site without being assigned a value, compilation fails with E0381:

1. Reading Immediately After Declaration

// ❌ does not compile
fn main() {
    let x: i32;
    println!("x = {x}");
}
error[E0381]: used binding `x` isn't initialized
 --> src/main.rs:3:20
  |
2 |     let x: i32;
  |         - binding declared here but left uninitialized
3 |     println!("x = {x}");
  |                    ^ `x` used here but it isn't initialized
  |
help: consider assigning a value
  |
2 |     let x: i32 = 42;
  |                ++++

error: aborting due to 1 previous error

2. Initializing in Only One Branch (if Without else)

If a variable is initialized inside an if block that has no else branch (or in some match arms but not others, unless the other arms diverge via return, break, or panic!), it remains possibly uninitialized afterward:

// ❌ does not compile
fn main() {
    let value: i32;
    let condition = true;

    if condition {
        value = 100;
    }

    println!("value = {value}");
}
error[E0381]: used binding `value` is possibly-uninitialized
  --> src/main.rs:10:24
   |
 2 |     let value: i32;
   |         ----- binding declared here but left uninitialized
...
 5 |     if condition {
   |        --------- if this `if` condition is `false`, `value` is not initialized
 6 |         value = 100;
 7 |     }
   |     - an `else` arm might be missing, initializing `value`
...
10 |     println!("value = {value}");
   |                        ^^^^^ `value` used here but it is possibly-uninitialized

error: aborting due to 1 previous error

If the else branch diverges (for example, executes return; or panic!()), then the only path that reaches println! is the true branch, so the program compiles cleanly:

fn print_if_ready(ready: bool) {
    let message: &str;

    if ready {
        message = "System is ready";
    } else {
        return; // Diverges! Execution never reaches `println!` when `ready == false`
    }

    println!("{message}");
}

fn main() {
    print_if_ready(true);
    print_if_ready(false);
}
System is ready

3. Initializing Inside Loops (loop vs while / for)

Because a while or for loop might execute zero times, initializing a variable inside a while or for loop does not satisfy definite initialization after the loop. By contrast, an unconditional loop is guaranteed to enter its body, so initializing a variable before every break inside a loop is accepted by the compiler:

fn main() {
    let computed: u32;
    let mut attempt = 1;

    loop {
        if attempt * attempt > 50 {
            computed = attempt; // Initialized before the only `break`
            break;
        }
        attempt += 1;
    }

    println!("First integer whose square > 50: {computed}");
}
First integer whose square > 50: 8

Reassigning a Possibly-Initialized Variable (E0384)

If an immutable variable let x; might be initialized inside a loop (or initialized once and then assigned a second time), the compiler rejects it with E0384 because the second assignment would mutate an already-initialized immutable variable:

// ❌ does not compile
fn main() {
    let x: i32;

    for i in 0..3 {
        x = i;
    }
}
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:5:9
  |
2 |     let x: i32;
  |         - help: consider making this binding mutable: `mut x`
...
5 |         x = i;
  |         ^^^^^ cannot assign twice to immutable variable

error: aborting due to 1 previous error

Drop Behavior of Uninitialized and Conditionally Initialized Variables

When a variable has a destructor (Drop, such as String), Rust only runs its destructor at scope exit if the variable was actually initialized!

  • If a variable is declared (let s: String;) and never initialized before the function exits, no destructor runs.
  • If a variable is initialized on one branch (if flag { s = String::from("hi"); }) and not on the other, the compiler automatically generates an internal boolean drop flag on the stack to track whether s was initialized at runtime and drops s at the end of the function only if the flag is set.