MaybeUninit and Uninitialized Memory

Updated

October 31, 2026

MaybeUninit and Uninitialized Memory

Safe Rust assumes every value is valid for its type: a bool is only true or false, a reference is non-null and aligned, a Vec<T> upholds its length/capacity invariants. Sometimes you need storage before those invariants hold — out-pointers, element-by-element arrays, FFI buffers. The standard tool is std::mem::MaybeUninit<T>: a wrapper that tells the compiler “these bytes might not be initialized yet.”

Do not reach for the deprecated mem::uninitialized or casual mem::zeroed on arbitrary T. Prefer MaybeUninit, initialize deliberately, then assume_init only when every validity requirement of T is met.

Mental model

MaybeUninit::uninit()     ← bytes may be garbage; Drop of T does NOT run
        │
   write / ptr::write     ← initialize without dropping old (uninit) contents
        │
 assume_init / assume_init_drop
        │
        ▼
       T                  ← normal Drop and safe APIs apply
API Role
MaybeUninit::uninit() Allocate storage; contents uninitialized
MaybeUninit::new(v) Already-initialized wrapper
MaybeUninit::zeroed() Fill with 0 bytes — only valid if all-zero is a valid T
write(val) Initialize (overwrites without dropping previous)
assume_init() Assert fully initialized; take ownership as T
assume_init_drop() Drop in place when you do not want ownership
as_mut_ptr() / &raw mut (*ptr).field Field-by-field init without creating &mut to uninit fields

Dropping a MaybeUninit<T> never runs T’s destructor. If you initialized and then abandon the wrapper without assume_init / assume_init_drop, you leak resources (safe but usually undesirable).

Worked examples

Case 1: write then assume_init

use std::mem::MaybeUninit;

fn main() {
    let mut slot = MaybeUninit::<Vec<u8>>::uninit();
    slot.write(vec![1, 2, 3]);
    let v = unsafe { slot.assume_init() };
    assert_eq!(v, vec![1, 2, 3]);
    println!("len={}", v.len());
}

write returns &mut T to the now-initialized contents. Calling write twice skips dropping the first value — a leak if T owns heap memory. Prefer one write, or drop explicitly between overwrites when you must replace.

Case 2: Out-pointer style

use std::mem::MaybeUninit;

unsafe fn fill_vec(out: *mut Vec<i32>) {
    // write does not drop old contents — correct for uninit destination
    unsafe { out.write(vec![10, 20, 30]); }
}

fn main() {
    let mut slot = MaybeUninit::uninit();
    unsafe { fill_vec(slot.as_mut_ptr()); }
    let v = unsafe { slot.assume_init() };
    assert_eq!(v, vec![10, 20, 30]);
}

The caller owns allocation strategy; the callee only initializes. After assume_init, Drop for Vec runs normally at end of scope.

Case 3: Array element-by-element

use std::mem::{self, MaybeUninit};

fn build_boxes() -> [Box<u32>; 4] {
    let mut data: [MaybeUninit<Box<u32>>; 4] = [const { MaybeUninit::uninit() }; 4];

    for (i, slot) in data.iter_mut().enumerate() {
        slot.write(Box::new(i as u32));
    }

    // SAFETY: every element was written
    unsafe { mem::transmute::<_, [Box<u32>; 4]>(data) }
}

fn main() {
    let arr = build_boxes();
    assert_eq!(*arr[3], 3);
    println!("{:?}", arr.iter().map(|b| **b).collect::<Vec<_>>());
}

Panic mid-loop leaks already-written Boxes (no destructor on remaining MaybeUninit), but does not invent UB from dropping uninitialized boxes. For partial init, track a length and call assume_init_drop on the live prefix in a cleanup path.

Case 4: Field-by-field struct init

use std::mem::MaybeUninit;

#[derive(Debug, PartialEq)]
struct Person {
    name: String,
    age: u32,
}

fn main() {
    let person = {
        let mut uninit = MaybeUninit::<Person>::uninit();
        let ptr = uninit.as_mut_ptr();

        // SAFETY: write through raw field pointers — do NOT create &mut to uninit fields
        unsafe {
            (&raw mut (*ptr).name).write(String::from("Ada"));
            (&raw mut (*ptr).age).write(36);
            uninit.assume_init()
        }
    };

    assert_eq!(
        person,
        Person {
            name: String::from("Ada"),
            age: 36
        }
    );
    println!("{person:?}");
}

Using assume_init_mut() to “assign fields” before the struct is fully initialized is UB — that creates references to uninitialized memory. Prefer &raw mut / ptr::write as above. Padding bytes need not be initialized.

Case 5: zeroed pitfalls

use std::mem::MaybeUninit;

fn main() {
    // OK: (u8, bool) can be all-zero
    let pair = unsafe { MaybeUninit::<(u8, bool)>::zeroed().assume_init() };
    assert_eq!(pair, (0, false));

    // NOT OK: &T must be non-null — zeroed reference is instantaneous UB
    // let bad: &i32 = unsafe { MaybeUninit::zeroed().assume_init() };

    // NOT OK: many enums reject discriminant 0
    // enum NonZero { A = 1, B = 2 }
    // let bad = unsafe { MaybeUninit::<NonZero>::zeroed().assume_init() };

    println!("pair={pair:?}");
}

Rule of thumb: zeroed().assume_init() only for types where every bit pattern of all-zeros is a valid value (raw integers, bool, structs of such). Never for references, NonNull, most enums, or “looks like a Vec with ptr=0”.

Choosing the API

Situation Prefer
Single value, known initializer MaybeUninit::new or plain T
Deferred init / out-pointer uninit + write / ptr::write
Large array filled in a loop [MaybeUninit<T>; N] + write each
Struct fields in sequence &raw mut (*ptr).field + write
Need all-zero bit pattern and it is valid zeroed with care
Old code with mem::uninitialized Port to MaybeUninit

Pitfalls

  1. assume_init too early — immediate UB, even for i32 / bool when bytes are still uninit.
  2. zeroed on references / invalid enums — classic footgun; all-zero is not “safe default” for every T.
  3. Double write without drop — leaks the first T.
  4. Creating & / &mut to uninitialized fields — use raw pointers / &raw mut until fully init.
  5. Transmuting &mut T to &mut MaybeUninit<T> for safe code — lets safe code invent uninit views; unsound.
  6. Assuming Miri-silence equals production-proof — still run tests under Miri for unsafe modules when you can.

Try this

  1. Run Case 1 and Case 3 under cargo +nightly miri run if Miri is available.
  2. Deliberately comment out one field write in Case 4 and observe that Miri / careful review catches the incomplete init (do not ship it).
  3. Replace a mem::uninitialized call in an old snippet with MaybeUninit and list which assume_init proof you now owe.
  4. Skim the Rustonomicon chapter on unchecked uninitialized memory and compare its array pattern to Case 3.

Sources