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:

  1. Every index must be written exactly once.
  2. No element may be read before it is written.
  3. 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

StrategyProsConsBest use case
T::default()Simple, safe, readableMay do unnecessary workSmall values, cheap defaults
vec![value; n]ConciseRepeats cloning or copyingRepeated cheap values
resize / resize_withFlexibleCan still initialize more than neededDynamic buffers
MaybeUninitAvoids wasted initializationRequires unsafe and careful invariantsLarge 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 push bounds 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 MaybeUninit when 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.

Learn more with useful resources