The gap between memory and value
You are building a parser for a binary file format. You know the header is exactly 64 bytes. You need a buffer to hold those bytes. In Python, you create a list and fill it. In Rust, if you try to create an array of a complex type without initializing every element, the compiler stops you. It demands every value be valid before it exists. Sometimes that demand is annoying. You need the memory slot, but you do not have the value yet. You are going to write the value in a loop, or call a C function that writes it for you. Rust gives you a tool for this gap between "memory exists" and "value is ready." That tool is MaybeUninit.
MaybeUninit<T> is a wrapper that tells the compiler: "This memory is reserved, but the data inside might be garbage. Do not assume it is a valid T." Think of it like a raw apartment unit in a new building. The walls are up, the doors are installed, but there is no furniture. You cannot invite guests over yet because there is nowhere to sit. The unit exists, but it is not livable. MaybeUninit is the unfurnished unit. You can move in furniture later. Once you have put in the couch and the bed, you declare the unit livable. That declaration converts the MaybeUninit into a real T. The compiler trusts you only after that declaration.
How MaybeUninit works
Rust runs destructors when values go out of scope. If you create a String, Rust allocates heap memory for the characters. When the String drops, Rust frees that heap memory. This is automatic and safe. It also has a cost. If you create a Vec<String> with ten thousand slots, initializing each String to empty allocates ten thousand tiny heap blocks. If you immediately overwrite every slot with real data, you just allocated and freed ten thousand blocks for no reason. That is wasted work.
MaybeUninit skips the initialization step. When you allocate MaybeUninit<T>, Rust reserves the bytes but does not run the constructor. No heap allocation happens for the inner T. No zeroing happens. You get raw memory. You can write data into that memory using write. write takes ownership of a value and places it into the slot. It does not drop the old content because the old content was uninitialized. When you are done, you call assume_init. This tells the compiler the memory now holds a valid T. The compiler starts tracking the value and will run the destructor when the scope ends.
If you call assume_init on garbage, the compiler will not catch it. The crash happens later when you try to use the garbage as if it were real. You might dereference a null pointer, or trigger a double-free. The compiler assumes you kept your promise.
Minimal example
use std::mem::MaybeUninit;
/// Demonstrates basic MaybeUninit usage: allocate, write, assume.
fn main() {
// Reserve space for an i32 without initializing it.
// The compiler knows this slot might contain garbage.
let mut slot: MaybeUninit<i32> = MaybeUninit::uninit();
// Write the value 42 into the memory slot.
// This populates the raw memory with a valid i32.
slot.write(42);
// Tell the compiler the slot is now valid.
// SAFETY: We called write(42) immediately before this.
// The memory contains a valid i32.
let value = unsafe { slot.assume_init() };
println!("{value}");
}
Walkthrough: allocation, writing, assuming
When MaybeUninit::uninit() runs, Rust allocates the bytes on the stack. For an i32, this is four bytes. The bytes contain whatever was in memory before. The compiler marks the variable as MaybeUninit<i32>. You cannot read the value. If you try to use slot as an i32, the compiler rejects you with E0308 (mismatched types). You have to go through the MaybeUninit API.
slot.write(42) copies the bits of 42 into the four bytes. This is a safe operation. You are writing, not reading. The function consumes the value 42 and places it into the slot. It returns nothing. The slot is now initialized, but the compiler still sees it as MaybeUninit. The compiler does not track initialization state at runtime. It relies on you to mark the transition.
unsafe { slot.assume_init() } performs a zero-cost transmute. It takes the MaybeUninit and hands back an i32. The compiler stops tracking the MaybeUninit and starts tracking the i32. When value goes out of scope, Rust runs the destructor for i32. For i32, the destructor is a no-op. For a String, the destructor would free the heap buffer. If you had called assume_init without writing, the destructor would run on garbage. That is undefined behavior.
Convention aside: keep the unsafe block as small as possible. Wrap only the assume_init call. Do not wrap the write call. write is safe. The community calls this the minimum unsafe surface rule. It makes the proof easier to verify.
Realistic example: filling an array
The most common real-world use is initializing arrays of complex types. Rust requires every element of an array to be initialized. If you have an array of MaybeUninit<T>, you can fill it element by element. This avoids initializing elements that will be overwritten.
use std::mem::MaybeUninit;
/// Creates a fully initialized array without initializing intermediate elements.
fn create_initialized_array() -> [String; 3] {
// Allocate an array of uninitialized slots.
// MaybeUninit arrays are valid even when uninitialized.
let mut buffer: [MaybeUninit<String>; 3] = unsafe {
// SAFETY: MaybeUninit has no initialization requirements.
// Creating an uninitialized MaybeUninit is always safe.
MaybeUninit::<[MaybeUninit<String>; 3]>::uninit().assume_init()
};
// Write values into each slot.
// write() places the value and consumes the slot.
buffer[0].write(String::from("Alpha"));
buffer[1].write(String::from("Beta"));
buffer[2].write(String::from("Gamma"));
// Convert to a valid array.
// SAFETY: We wrote to indices 0, 1, and 2.
// Every element in the array is initialized.
unsafe { buffer.assume_init() }
}
The first unsafe block creates the array itself. MaybeUninit::<[MaybeUninit<String>; 3]>::uninit() creates an uninitialized MaybeUninit that holds an array. The inner array is also uninitialized. This is safe because MaybeUninit does not require initialization. You can have an uninitialized MaybeUninit. The assume_init converts it to a [MaybeUninit<String>; 3]. Now you have an array of slots.
You fill each slot with write. write is safe. It places the String into the slot. The String allocates heap memory for the characters. The slot now holds a valid String.
The second unsafe block converts the array. buffer.assume_init() transmutes the array of MaybeUninit to an array of String. This is safe because you wrote to every index. The compiler now sees a [String; 3]. When the function returns, the caller owns the array. When the caller drops it, Rust drops each String and frees the heap memory.
Convention aside: use assume_init on the array, not transmute. transmute works, but assume_init is the idiomatic helper. It conveys intent clearly. If you use transmute, reviewers will ask why.
Pitfalls and undefined behavior
Reading uninitialized memory is undefined behavior. If you call slot.as_ptr().read(), you get garbage. The compiler will not stop you if you use unsafe. You might get a random number, or you might crash. The behavior is unpredictable. Do not read from MaybeUninit until you have written to it.
The drop trap is subtle. If you assume_init and then the value goes out of scope, Rust runs the destructor. If you forgot to write, the destructor runs on garbage. For a String, this might try to free a random pointer. That corrupts the heap. The crash might happen seconds later. Debugging this is painful. Track initialization state carefully. Use a boolean flag if you have a complex loop.
Assignment is dangerous. If you use slot = MaybeUninit::uninit() after writing, you overwrite the slot. The old value is lost. If the old value was initialized, you leak memory. write is the correct way to populate. write takes ownership and places the value. It does not drop the old content. If you need to overwrite an initialized slot, drop it first.
Compiler error E0133 (dereference of raw pointer requires unsafe) appears if you try to dereference a raw pointer without unsafe. MaybeUninit gives you raw pointers via as_ptr. You must use unsafe to read or write through them. The compiler enforces this boundary. Respect it.
Treat the SAFETY comment as a proof. If you cannot write the invariants, you do not have a proof. List the conditions that make the unsafe block valid. Reviewers will check the proof. If the proof is weak, the code is rejected.
When to use MaybeUninit
Use MaybeUninit when you need to allocate memory for a type that cannot be default-initialized, such as a struct with a field that has no Default implementation and no way to construct a dummy value. Use MaybeUninit when you are implementing a custom allocator or a data structure like a ring buffer where you write raw bytes and construct values in place. Use MaybeUninit when you are filling an array or vector from an external source, like a C function or a file read, and you want to avoid initializing elements that will be overwritten immediately. Reach for Vec::with_capacity when you need a dynamic list and you will push elements one by one; Vec handles the uninitialized memory internally for you. Reach for Option<T> when you have a value that might be missing but is initialized to None; Option is safe and handles the logic for you. Reach for Default::default() when the type supports it and you just need a placeholder; initializing with a default is safer than leaving memory uninitialized.
MaybeUninit is a scalpel. Use it for precision. Use Vec for surgery.