The cost of safety
You're building a physics simulation. Every frame, you spawn a thousand particles. The profiler screams that half your CPU time vanishes into memory zeroing. Rust zero-initializes every i32 and f64 by default to keep you safe. That safety costs cycles. You want to skip the zeroing. You want to write directly into raw memory and tell the compiler, "Trust me, I put the data there." MaybeUninit is the tool for that job.
Profile before you panic. Zeroing is fast on modern hardware, but when you allocate millions of small objects, the overhead adds up. MaybeUninit lets you opt out of that overhead when the math proves it matters.
What MaybeUninit actually does
Rust guarantees that every variable contains a valid value. When you write let x: i32;, the compiler forces you to initialize x before reading it. Under the hood, Rust often fills the memory with zeros before your code runs. This prevents reading garbage and ensures that destructors run on valid data.
MaybeUninit<T> breaks that guarantee. It represents a chunk of memory that might or might not hold a valid T. Think of a standard variable like a filled-out form. The system hands you a form that's already pre-printed with default values. Even if you're going to scribble over every line, the printer still runs first. MaybeUninit is like a blank sheet of paper. The system hands you the paper without printing anything. You can write whatever you want.
There's a catch. If you hand that blank paper to someone who expects a filled form, they'll read gibberish. You have to track exactly when the paper goes from blank to filled. If you claim the paper is filled when it's still blank, you get undefined behavior. The compiler won't save you. You own the correctness.
Treat uninitialized memory as a debt. You must pay it back before the scope ends.
Minimal example
Here's the basic pattern. You create an uninitialized slot, write a value into it, and convert it to a normal type.
use std::mem::MaybeUninit;
/// Allocates an i32 without zeroing, then writes 42.
fn fast_alloc() -> i32 {
// Create a slot for an i32.
// It contains garbage bits, not zero.
// No initialization cost occurs here.
let mut slot = MaybeUninit::<i32>::uninit();
unsafe {
// Write 42 directly into the memory slot.
// ptr::write overwrites bytes without dropping anything.
// This is safe because the slot is uninitialized.
std::ptr::write(slot.as_mut_ptr(), 42);
// Tell the compiler the slot is now valid.
// Returns a normal i32.
// The caller now owns the value.
slot.assume_init()
}
}
Keep the unsafe block tight. If it spans more than a screen, you're doing it wrong.
Walkthrough
When you call MaybeUninit::uninit(), Rust allocates space on the stack. It does not touch the bits. The memory holds whatever was there before. This is the performance win. The CPU skips the store instructions that would write zeros.
When you call ptr::write, you overwrite those bits with your value. ptr::write is crucial here. It copies bytes into the destination without dropping the old value. Since the slot is uninitialized, there is no old value to drop. If you used assignment like *slot.as_mut_ptr() = 42, Rust would try to drop the garbage value first. That triggers undefined behavior because the garbage isn't a valid i32.
Finally, assume_init() tells the type system to treat that memory as a valid T. The compiler stops tracking the initialization state and hands you a normal value. The value is now subject to normal drop rules. If the function returns, the i32 drops when the caller drops it.
The compiler trusts your unsafe block. If you lie, the CPU pays the price.
Realistic example: Filling a buffer
Real code rarely allocates one integer. You usually fill a buffer. Imagine a parser building a token array. You don't know the tokens yet. You want to reserve space and fill it as you parse.
use std::mem::MaybeUninit;
use std::ptr;
/// Fills an array of u64 without zeroing first.
fn fill_buffer() -> [u64; 4] {
// Reserve space for four u64s.
// All slots are uninitialized.
// uninit_array is the safe way to create an array of uninit elements.
let mut buffer = MaybeUninit::<[u64; 4]>::uninit_array();
unsafe {
// Get a pointer to the array memory.
// as_mut_ptr() returns *mut [u64; 4].
// We cast to *mut u64 to index elements.
let ptr = buffer.as_mut_ptr() as *mut u64;
// Write values one by one.
// ptr::write avoids dropping anything because the slots are empty.
// Each write fills one slot.
ptr::write(ptr.add(0), 100);
ptr::write(ptr.add(1), 200);
ptr::write(ptr.add(2), 300);
ptr::write(ptr.add(3), 400);
// All slots are filled.
// Convert to initialized array.
// Returns [u64; 4].
buffer.assume_init()
}
}
Use uninit_array for arrays. Pointer casting is a trap for the unwary.
The community convention is to use MaybeUninit::uninit_array() when available. It creates an array where each element is uninitialized. Older code casts pointers and does arithmetic. That works, but uninit_array expresses intent clearly and avoids layout assumptions.
The trap: Types with destructors
MaybeUninit is simple for types like i32 or f64. They have no destructor. You write bytes, you're done. Things get dangerous when T implements Drop.
If you write a Vec<String> into a MaybeUninit, that Vec owns heap memory. If you assume_init and return, the Vec drops correctly. But what if you panic after writing the first element of an array? The Vec never gets dropped. You leak memory. Or worse, what if you assume_init and then the scope ends, but you also manually dropped the value? You get a double-free.
You need a state machine in your head. Track every byte. If you have a type with Drop, you must ensure the destructor runs exactly once. This often means wrapping the logic in a helper that handles partial initialization.
If your type implements Drop, you need a state machine in your head. Track every byte.
Pitfalls and errors
The biggest pitfall is reading uninitialized memory. If you read a MaybeUninit before writing, you get undefined behavior. The compiler won't stop you inside unsafe. You have to track it yourself.
If you try to dereference the raw pointer without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). That's a good error. It stops you. The bad errors are the ones that don't happen. If you call assume_init on a partially filled array, the compiler accepts it. The program crashes later in a random place.
Another pitfall is mixing ptr::write and assignment. Use ptr::write for uninitialized slots. Use *ptr = val for initialized slots. Mixing them up causes double-free or use-after-free.
assume_init is a promise. Break it, and you get undefined behavior, not a compiler error.
When to use MaybeUninit
Use MaybeUninit when profiling proves zero-initialization is the bottleneck and you control the entire lifetime of the memory. Use MaybeUninit when you are writing a low-level abstraction like an allocator, a ring buffer, or a custom smart pointer. Use MaybeUninit when you need to perform a swap or rotation of values without dropping or copying, using std::mem::swap on raw pointers. Reach for Vec::with_capacity when you need a growable buffer; it avoids zeroing until you push, and handles drops safely. Reach for std::array::from_fn when you need to initialize an array with a closure; it's safe and often optimizes well.
Reach for safe abstractions first. MaybeUninit is the escape hatch, not the front door.