
Optimizing Rust with `std::mem::MaybeUninit` for Zero-Cost Initialization
What MaybeUninit is for
MaybeUninit<T> represents memory that may or may not contain a valid T. Unlike T, it does not require initialization before allocation. This makes it useful when:
- You want to fill a buffer element by element.
- You need to construct large arrays without first creating placeholder values.
- You are interfacing with APIs that write into caller-provided memory.
- You want to avoid
T::default()or repeated temporary allocations.
The key idea is simple: allocate memory first, initialize only the parts you need, and convert to a fully initialized value only when you are sure it is valid.
When it helps
MaybeUninit is most valuable when initialization itself is expensive. For example:
- A
Vec<[u8; 4096]>where each element would otherwise be zeroed and then overwritten. - A large fixed-size array of structs that are immediately populated from I/O.
- A custom parser that fills a result structure field by field.
If the data is small or initialization is cheap, MaybeUninit may add complexity without measurable benefit.
The performance problem: unnecessary initialization
Consider a function that creates a large array and then overwrites every element:
fn build_table() -> [u64; 1024] {
let mut table = [0u64; 1024];
for i in 0..table.len() {
table[i] = (i as u64).wrapping_mul(37);
}
table
}This is perfectly safe, but it initializes the entire array to zero first. If the array is large and the values are always overwritten, that zeroing is wasted work.
A similar issue appears with types that have nontrivial Default implementations. If you create a Vec<MyStruct> with resize or vec![Default::default(); n], you may pay for construction of values that are immediately replaced.
A practical pattern: fill an array without default construction
The most common use of MaybeUninit is building an array element by element.
use std::mem::MaybeUninit;
fn build_table() -> [u64; 1024] {
let mut data: [MaybeUninit<u64>; 1024] = MaybeUninit::uninit_array();
for i in 0..1024 {
data[i].write((i as u64).wrapping_mul(37));
}
unsafe { MaybeUninit::array_assume_init(data) }
}Why this is faster
- No initial zero-fill of the array.
- Each slot is written exactly once.
- The compiler can often optimize the loop well because the memory layout is simple.
Why the unsafe is necessary
Rust cannot prove that every element was initialized. The final conversion requires unsafe because the compiler must trust you that all elements contain valid u64 values.
The safety contract is straightforward:
- Every index must be written exactly once.
- No element may be read before it is written.
- The array must not be converted unless all elements are initialized.
If any of those rules are violated, behavior is undefined.
Building partially initialized data safely
A common real-world use case is reading from an external source into a fixed-size buffer. Suppose a network protocol returns exactly 64 bytes, but you want to avoid creating a temporary zeroed array first.
use std::io::{self, Read};
use std::mem::MaybeUninit;
fn read_exact_64<R: Read>(reader: &mut R) -> io::Result<[u8; 64]> {
let mut buf: [MaybeUninit<u8>; 64] = MaybeUninit::uninit_array();
let mut filled = 0;
while filled < 64 {
let dst = unsafe {
std::slice::from_raw_parts_mut(
buf.as_mut_ptr().cast::<u8>().add(filled),
64 - filled,
)
};
let n = reader.read(dst)?;
if n == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read"));
}
filled += n;
}
unsafe { Ok(MaybeUninit::array_assume_init(buf)) }
}This example shows the core tradeoff: more control and less wasted initialization, but more responsibility for correctness.
Best practice
Use this pattern only when the data source guarantees full initialization. If reads can fail early, you need cleanup logic for any partially initialized elements.
Handling partial initialization with guards
When constructing a complex value field by field, you often need to clean up already-initialized fields if an error occurs. A guard object can make this safe.
use std::mem::MaybeUninit;
struct Pair {
a: String,
b: String,
}
fn build_pair(x: &str, y: &str) -> Result<Pair, &'static str> {
let mut data: MaybeUninit<Pair> = MaybeUninit::uninit();
unsafe {
let ptr = data.as_mut_ptr();
std::ptr::addr_of_mut!((*ptr).a).write(x.to_owned());
if y.is_empty() {
std::ptr::drop_in_place(std::ptr::addr_of_mut!((*ptr).a));
return Err("empty input");
}
std::ptr::addr_of_mut!((*ptr).b).write(y.to_owned());
Ok(data.assume_init())
}
}This works, but it is easy to get wrong. If the second write panics or returns an error, you must ensure the first field is dropped exactly once.
Safer alternative
For complex types, prefer higher-level construction unless profiling shows a real bottleneck. MaybeUninit is best when the initialization pattern is simple and mechanical.
Comparison: common initialization strategies
| Strategy | Pros | Cons | Best use case |
|---|---|---|---|
T::default() | Simple, safe, readable | May do unnecessary work | Small values, cheap defaults |
vec![value; n] | Concise | Repeats cloning or copying | Repeated cheap values |
resize / resize_with | Flexible | Can still initialize more than needed | Dynamic buffers |
MaybeUninit | Avoids wasted initialization | Requires unsafe and careful invariants | Large arrays, FFI, hot paths |
This table is a useful rule of thumb: if the performance gain is not obvious, start with the safe, idiomatic approach and measure first.
MaybeUninit with vectors and spare capacity
Vec<T> already manages allocation efficiently, but MaybeUninit can help when you want to write directly into spare capacity without creating temporary values.
A common pattern is to reserve space, write into the uninitialized tail, then update the length.
use std::mem::MaybeUninit;
fn fill_vec(n: usize) -> Vec<u32> {
let mut v: Vec<u32> = Vec::with_capacity(n);
unsafe {
let ptr = v.as_mut_ptr();
for i in 0..n {
ptr.add(i).write((i as u32) * 2);
}
v.set_len(n);
}
v
}Why this matters
This avoids:
- repeated
pushbounds checks in some scenarios, - temporary intermediate values,
- default initialization of unused elements.
Important caveat
set_len is unsafe because it tells Rust the vector contains initialized elements. If a panic occurs before set_len, the vector must still not think those elements exist. If you write this pattern, keep the unsafe block small and tightly scoped.
FFI and system APIs
MaybeUninit is especially useful when calling C APIs that write into caller-provided memory.
For example, many system calls expect a pointer to output storage:
use std::mem::MaybeUninit;
extern "C" {
fn get_value(out: *mut u32) -> i32;
}
fn read_value() -> Result<u32, i32> {
let mut out = MaybeUninit::<u32>::uninit();
let status = unsafe { get_value(out.as_mut_ptr()) };
if status == 0 {
Ok(unsafe { out.assume_init() })
} else {
Err(status)
}
}This avoids initializing u32 before the foreign function overwrites it.
FFI best practices
- Only use
MaybeUninitwhen the API guarantees it writes a valid value on success. - Never assume initialization on failure paths.
- Keep the unsafe boundary as narrow as possible.
Common mistakes to avoid
1. Reading before initialization
Never call assume_init early or inspect memory through a reference before the value is fully written.
2. Forgetting to initialize every element
For arrays and slices, one missing write is enough to make the final conversion invalid.
3. Double-dropping values
If you manually initialize fields, be careful not to drop them twice during error handling.
4. Using it where it does not help
If the type is tiny or the initialization cost is negligible, MaybeUninit adds complexity without meaningful speedup.
5. Exposing unsafe internals in public APIs
Prefer to keep MaybeUninit inside private implementation details. Public APIs should return fully initialized safe types.
A decision checklist
Before using MaybeUninit, ask:
- Is initialization actually a measurable cost?
- Can I prove every element will be written exactly once?
- Do I need to interoperate with an API that writes into memory?
- Can I keep the unsafe code small and auditable?
- Would a simpler safe approach be fast enough?
If the answer to any of these is unclear, benchmark the safe version first.
Measuring the benefit
Performance work should be data-driven. Use benchmarks to compare the safe and MaybeUninit versions under realistic workloads.
Focus on:
- total runtime,
- allocation count,
- memory bandwidth,
- branch behavior if initialization logic is conditional.
A microbenchmark that only measures construction in isolation may overstate the benefit. In real code, the cost of initialization may be hidden by parsing, I/O, or downstream processing.
Practical guidance
- Benchmark release builds only.
- Use representative input sizes.
- Compare against a baseline implementation.
- Validate correctness with tests, especially for edge cases and failure paths.
When MaybeUninit is the right tool
MaybeUninit is a precision instrument. It is not a general replacement for normal Rust initialization, but it is excellent when you need to:
- build large arrays efficiently,
- write directly into buffers,
- avoid placeholder values,
- interface with low-level APIs,
- control initialization in performance-critical code.
The performance win comes from eliminating work, not from magic. If you are initializing thousands of elements that will be overwritten anyway, MaybeUninit can remove a real bottleneck. If not, idiomatic safe Rust is usually the better choice.
