The Rust Engineering Handbook / Chapter 67
Validity, Initialization, MaybeUninit, and ManuallyDrop
Reason about storage, valid typed values, partial construction, and exactly-once destruction when Rust cannot track initialization for you.
Construction stops while producing element three of a five-element array. Elements zero, one, and two each own a heap allocation. The remaining slots have never contained a T. The constructor now has one job that ordinary [T; 5] cannot express: destroy exactly the three live values without reading or dropping either vacant slot.
That interruption is the useful starting point for initialization. The bytes are not merely “filled” or “empty.” At each slot, the implementation must distinguish storage that may hold arbitrary state from storage that currently represents a valid value of a particular type. It must also track whether ownership of that value still belongs to the constructor. A counter that advances only after a successful write can encode all three facts.
Suppose the counter is len = 3 when element construction returns an error or panics:
slot 0 1 2 3 4
state valid T valid T valid T uninitialized uninitialized
owner guard guard guard nobody nobody
cleanup drop drop drop no access no access
The central rule is stricter than “do not dereference uninitialized memory”:
A program may treat storage as a
Tonly after it contains a value valid forT. Once a liveTexists, ownership accounting must arrange exactly one logical destruction or an intentional leak.
The first half is a validity proof. The second is a resource proof. MaybeUninit<T> helps represent the boundary, but it does not prove that a write happened, that the written bits are valid, or that destruction occurs once.
Four states that reviews often collapse
Initialization work becomes easier to audit when four questions stay separate.
- Is there storage? An allocation or stack slot may exist without containing a value.
- Have the relevant bytes been initialized? Uninitialized bytes are not ordinary unknown bytes. Reading them as a typed scalar is not a nondeterministic input operation.
- Do the bytes satisfy the validity rules of
T? An initialized byte pattern can still be invalid forbool, a reference, an enum,NonZeroU32, or a compound value containing one of them. - Who owns the live value and owes its destructor? A valid
Stringcopied withptr::readinto a second owner can be dropped twice even though both copies initially have a valid representation.
These questions produce different failures. Reading a never-written u32 produces an invalid value because even scalar integers must be initialized. Treating the byte 2 as bool violates bool’s valid-value set. Forgetting to drop an initialized String leaks its allocation but normally preserves memory safety. Dropping the same String twice may free one allocation twice and cause undefined behavior.
Validity is type-directed. The current Rust Reference lists examples: bool permits only 0 and 1; char excludes surrogate code points and values above char::MAX; references and Box<T> have non-null, alignment, liveness, and pointee-validity requirements; an enum needs a valid discriminant and valid fields for the active variant. Structs, tuples, and arrays require their elements to be valid recursively.
Not every logical invariant is a Rust validity invariant. A u32 account identifier may reserve zero by application policy while zero remains a valid u32. A UTF-8 parser may reject control characters even though those bytes are valid u8 values. Moving application policy into unsafe validity reasoning makes an API harder to audit and can push checkable conditions onto callers unnecessarily.
The Reference also warns that its undefined-behavior list and some details of validity are not a complete, frozen formal model. Safety arguments should use documented type and library contracts, avoid exploiting unspecified corners, and record uncertainty rather than turning one current tool result into a universal rule.
MaybeUninit<T> changes the type of the claim
MaybeUninit<T> is a union designed to hold storage that may not yet contain a T. Creating MaybeUninit::<T>::uninit() is safe because the resulting value is a valid MaybeUninit<T> regardless of whether a T is present. Creating a reference to the wrapper is also safe. Claiming that its contents are a T is the unsafe transition.
Three operations express the normal lifecycle:
use std::mem::MaybeUninit;
let mut slot = MaybeUninit::<String>::uninit();
slot.write(String::from("wireview"));
// SAFETY: `write` completed and no operation has moved or dropped the value.
let value = unsafe { slot.assume_init() };
assert_eq!(value, "wireview");
write is important. Ordinary assignment to a place typed as an initialized T first treats the old occupant as a live value that may need dropping. An uninitialized slot has no old T to drop. MaybeUninit::write writes without trying to destroy prior contents and returns a mutable reference to the newly written value.
The unsafe assume_init consumes the wrapper and asserts that a valid T is present. Related methods make different ownership claims:
assume_init_reforassume_init_mutcreates a reference to an already initialized value; neither method is an initialization mechanism;assume_init_readperforms a bitwise read and transfers aTout, so calling it twice for a non-Copyvalue can create two owners;assume_init_dropruns the destructor in place and must be called only for an initialized value that has not already been dropped.
Dropping the wrapper itself never drops T. This is necessary for uninitialized storage, but it means MaybeUninit::new(String::from("x")) leaks the string if the wrapper goes out of scope without an explicit transfer or drop. Memory safety and resource correctness are related but not identical.
The visual below shows the proof boundary to inspect: the type changes only after every slot in the claimed prefix has crossed from storage to a valid value, and cleanup visits only that prefix.

A fixed buffer makes the initialized prefix explicit
The memory-contracts-lab fixture implements a bounded owner. Its representation invariant is compact enough to state before reading any unsafe operation:
len <= N;- every slot in
slots[..len]contains one live, validTowned by the buffer; - every slot in
slots[len..]is outside the typed-value set and must not be read or dropped asT.
The storage and insertion path are safe:
use std::mem::MaybeUninit;
pub struct FixedBuffer<T, const N: usize> {
slots: [MaybeUninit<T>; N],
len: usize,
}
impl<T, const N: usize> FixedBuffer<T, N> {
pub const fn new() -> Self {
Self {
slots: [const { MaybeUninit::uninit() }; N],
len: 0,
}
}
pub fn push(&mut self, value: T) -> Result<(), T> {
if self.len == N {
return Err(value);
}
self.slots[self.len].write(value);
self.len += 1;
Ok(())
}
}
The order of the last two statements is the transaction boundary. write must finish before len grows. If evaluating or obtaining value fails before push, the buffer state does not change. If the counter advanced first and a later operation panicked, Drop would treat an unwritten slot as a live T.
This implementation stores the cleanup guard in the object itself. A separate private guard containing a pointer to an output array and an initialized count is another credible design. The invariant is the same: the count describes a contiguous prefix, and the guard owns cleanup until it is deliberately disarmed.
The slice view is the first unsafe assertion:
pub fn as_slice(&self) -> &[T] {
// SAFETY: The first `len` slots contain initialized, valid `T` values.
// `MaybeUninit<T>` has the same size and alignment as `T`; the borrow of
// `self` supplies the lifetime and prevents mutation for that lifetime.
unsafe {
std::slice::from_raw_parts(
self.slots.as_ptr().cast::<T>(),
self.len,
)
}
}
The proof has more than one premise. MaybeUninit<T> is guaranteed to have the same size, alignment, and ABI as T; the prefix invariant establishes validity; len establishes the range; and the borrow of self establishes lifetime and shared-access behavior. The wrapper’s layout guarantee alone does not make every slot a T.
A mutable slice requires the same initialization proof plus exclusivity. The method takes &mut self, so safe callers cannot use another access through this buffer while the returned slice is live. The implementation must still avoid creating any internal competing reference or callback access.
Partial construction is a cleanup protocol
The constructor fills the buffer one element at a time:
pub fn try_from_fn<E>(
mut make: impl FnMut(usize) -> Result<T, E>,
) -> Result<Self, E> {
let mut output = Self::new();
for index in 0..N {
let value = make(index)?;
let inserted = output.push(value);
debug_assert!(inserted.is_ok(), "loop visits exactly N vacant slots");
}
Ok(output)
}
The fixture avoids the illustrative expect and uses a debug assertion because the loop bound already proves capacity. The essential behavior is that output remains an ordinary local owner during every call to make. On Err, ? drops it. On panic, stack unwinding drops it when the panic strategy unwinds. Its destructor visits only the initialized prefix:
impl<T, const N: usize> Drop for FixedBuffer<T, N> {
fn drop(&mut self) {
for slot in &mut self.slots[..self.len] {
// SAFETY: Exactly this prefix is initialized, and each slot is
// visited once by this destructor.
unsafe { slot.assume_init_drop() };
}
}
}
The deterministic tests inject both an error and a panic at index three. A DropProbe increments a counter from its destructor. Each path observes exactly three drops. This evidence checks the intended transitions, but it does not prove the unsafe slice cast or destructor correct for every T; the invariant and standard-library contracts carry that argument.
Panic safety depends on the build and boundary. With panic = "unwind", local destructors normally run while unwinding. With panic = "abort", the process stops without stack cleanup, so partial elements may not run destructors before termination. That difference is usually a resource and external-effect concern, not permission to leave a reachable object in an invalid state. A constructor must also consider panics from user-supplied closures, allocation, logging, and destructors themselves.
A destructor that panics creates a narrower but important failure case. If one element’s destructor panics while the prefix loop is already unwinding, Rust normally aborts rather than continue through a second panic. If it panics during ordinary destruction, later elements in a hand-written loop may not be visited, leaking their resources. The unsafe invariant must remain sound even though cleanup is incomplete: already-dropped elements must not be dropped again, and no invalid value may become reachable. Types whose destructors can panic are therefore hostile inputs for low-level containers. Document whether T: Drop is expected to avoid panic, keep external effects idempotent where possible, and do not add callbacks or logging between metadata updates and element destruction. A leak caused by abort or an interrupted destructor is not equivalent to double drop; incident analysis should classify the two separately.
If initialization is not a prefix—for example, work completes out of order—one counter is insufficient. Use a bitmap, per-slot state, or a design that initializes sequentially. The cleanup representation must describe the real initialized set. Selecting a simpler construction order often removes more unsafe state than improving the bitmap implementation.
Array construction has three separate handoffs
Element-wise array construction is not one unsafe conversion. It has three handoffs worth reviewing.
The first moves each newly produced T into one vacant slot. The constructor must update initialized-set metadata only after that move. The second converts a fully initialized storage representation into [T; N]; this asserts that every element is present and valid. The third transfers cleanup ownership from the partial-construction guard to the returned array. If the old guard remains armed after the transfer, both owners will drop the elements.
For a prefix-based constructor, write the state machine explicitly:
(storage, len = k)
-- successful write to slot k --> (storage, len = k + 1)
-- error or panic -------------> drop slots 0..k, then stop
(storage, len = N)
-- transfer complete array ----> returned [T; N], old guard disarmed
There is no transition from len = k to len = k + 1 on failed element construction. There is no transition to [T; N] while len < N. There is no path that both returns the array and leaves the old destructor responsible for its elements.
The zero-capacity case deserves a test. [T; 0] contains no elements, so construction should not call the producer, cleanup should drop nothing, and transfer should produce the empty array without dereferencing element storage. Generic unsafe code often exposes assumptions such as “the base points to the first element” only when N = 0.
For large arrays, a standard-library safe constructor or collection conversion may already express the job and should be preferred when it meets the project’s MSRV. If a convenience API is newer than the supported compiler, keep the compatibility choice visible: retain a small audited implementation, raise the MSRV deliberately, or use Vec<T> and a checked conversion. Do not silently copy an unstable or newly stabilized pattern into core code.
Out-pointers split storage ownership from value ownership
An out-pointer lets a callee initialize storage chosen by its caller. The caller initially owns storage but no T; the callee receives permission to write; after a success signal, the caller may assume ownership of a valid T. On failure, the protocol must say whether nothing was written, a prefix was written, or a complete value exists but accompanies an error.
A Rust-shaped boundary accepts *mut MaybeUninit<T> or another representation that does not claim a live T too early. The callee uses write, not assignment through *mut T, because assignment may try to drop an alleged old occupant. The caller invokes assume_init only after the callee’s documented success condition.
Foreign APIs often weaken this neat protocol. A return code may indicate that some fields are meaningful on failure; a length output may itself be uninitialized unless another flag is set; a library may retain the pointer asynchronously. Model each output independently instead of turning the entire foreign struct into T at the first write. Byte-oriented foreign structs also require the representation and FFI contracts developed later in Part XI.
Out-pointers are valuable when allocation placement, ABI, pinning, or a measured large-value move matters. They are not automatically faster than returning a value. Rust’s calling convention and optimizer may already construct results efficiently, while an out-pointer permanently adds an unsafe initialization protocol. Measure the actual boundary before choosing it for performance.
ManuallyDrop<T> suppresses destruction, not validity
ManuallyDrop<T> answers a different question. It contains a valid T but inhibits automatic destructor execution. It has the same layout and bit validity as T. That makes zero-initializing ManuallyDrop<&mut T> invalid for exactly the same reason as zero-initializing &mut T; use MaybeUninit<T> for storage that may not contain T.
The fixed buffer uses ManuallyDrop only at a local ownership-transfer boundary. A full buffer can become [T; N] without allowing the buffer destructor to run afterward:
pub fn into_array(self) -> Result<[T; N], Self> {
if self.len != N {
return Err(self);
}
let this = std::mem::ManuallyDrop::new(self);
// SAFETY: Every slot contains a valid T. `ptr::read` transfers the array,
// and ManuallyDrop prevents FixedBuffer::drop from dropping it again.
Ok(unsafe {
std::ptr::read(this.slots.as_ptr().cast::<[T; N]>())
})
}
After ptr::read, the returned array owns the values. The old bytes still physically resemble them, but no second logical owner may read or drop those bytes. ManuallyDrop prevents automatic cleanup of the old container, and the implementation never exposes it again.
This narrow local use is easier to defend than storing ManuallyDrop<T> in a public generic type. The standard-library documentation identifies serious hazards: manually dropping a field and then exposing the wrapper through safe derived traits can let Debug, Clone, or another derived implementation access a destroyed value. A client may instantiate generic T with a Box, interacting with rules around moving a manually dropped value. Calling ManuallyDrop::drop twice can invoke a destructor twice. Safe access to ManuallyDrop<T> means an already-dropped “zombie” must not remain observable through a safe API.
Prefer ordinary field declaration order when the goal is merely destruction order. Rust drops struct fields in declaration order, and locals in reverse declaration order. Reordering fields preserves compiler ownership tracking. Replacing that with manual destruction introduces unwind, move, trait-derivation, and maintenance obligations.
Zero bytes do not mean a default value
MaybeUninit::zeroed fills storage with zero bytes, subject to the documented caveat that padding bytes may not remain zero through value movement. It does not establish that zero is a valid T. The caller of assume_init still proves validity.
Zero is valid for many integer and floating types and for a tuple whose components all accept zero. It is not a valid reference, Box<T>, function pointer, NonZeroU32, or enum without a zero discriminant. A struct is zero-valid only if every field is zero-valid and the representation contract supports the claim. Adding one field can invalidate a formerly reviewed zero-initialization site without changing that site.
This is why “C initializes the output to zero” is incomplete FFI evidence. The review needs the exact Rust destination type, ABI representation, which bytes the foreign function writes, whether it reports partial failure, and whether Rust can observe the destination before a complete valid value exists. Byte buffers such as [MaybeUninit<u8>] are often a better boundary than pretending a foreign operation has already constructed a rich Rust type.
Do not use zeroing as a general performance optimization without measurement. The operating system or allocator may already provide demand-zeroed pages in some paths, while explicit zeroing can consume memory bandwidth. Conversely, security policy may require clearing sensitive buffers, but ordinary optimization and drop behavior can complicate that guarantee; use a reviewed secrecy mechanism and verify its platform contract.
Padding is not spare application storage
Padding permits a type to meet alignment and layout constraints. Even when all fields are valid, padding bytes may remain uninitialized. Moving or copying a value may fail to preserve padding contents. Therefore, whole-object byte comparison, hashing, serialization, or exposure across a trust boundary cannot assume padding holds stable data.
The safe equality implementation for a struct compares fields, not every byte in its footprint. A stable wire encoding writes defined fields into an explicit byte format. If an FFI or hashing design requires all bytes to be initialized, define and verify a representation designed for that boundary rather than inferring it from one compiler layout.
Unions make active interpretation an explicit obligation. Writing a union field is safe because it does not need to read or drop an old field as that type. Reading most union fields is unsafe because the selected interpretation must be valid for the field type. The bits do not carry a runtime tag saying which interpretation is valid. MaybeUninit<T> uses a union to make the potentially uninitialized state representable, but its safe methods still do not invent proof that a T has been written.
The exact validity rules for arbitrary union values continue to have unsettled details. Use unions for documented representation jobs, keep the active-field protocol explicit, and avoid making safety depend on speculative interpretations of padding or inactive fields.
References may be created only after validity exists
A reference is not a harmless pointer-shaped observation. Creating &T asserts alignment, non-nullness, liveness, pointee validity, and shared-reference aliasing conditions. Creating &mut T adds exclusivity. This means code must not create &mut T to uninitialized storage and then plan to initialize through it; the invalid reference already exists before the write.
Use raw pointers or MaybeUninit::write while constructing. For field-by-field initialization, &raw mut can form a raw pointer without creating an intermediate reference, and ptr::write can initialize a field without dropping an old value. The proof must cover every field and cleanup on interruption before assume_init produces the complete struct.
Field-by-field construction is often less robust than constructing ordinary local values and then assembling the struct in safe code. It is justified for out-pointers, immovable values, very large objects where a measured copy matters, or foreign initialization contracts—not as a stylistic preference.
Drop flags are semantic bookkeeping, not an exposed API
In safe Rust, the compiler tracks moves well enough to avoid dropping moved-out locals and fields. Discussions often call the resulting bookkeeping “drop flags.” Do not depend on a particular hidden flag layout or assume that every value contains a runtime boolean. The semantic guarantee is that initialized owned values are dropped according to Rust’s rules; the compiler may implement that with static analysis, control flow, or runtime state.
Unsafe containers reintroduce explicit bookkeeping because the compiler sees [MaybeUninit<T>; N], whose elements need no T destructor. The len field is therefore part of the trusted computing base. Every method that changes it deserves unsafe review even if that method contains no unsafe block. push, pop, into_array, and Drop jointly form one ownership state machine.
For pop, decrementing the counter before assume_init_read removes the slot from the buffer’s owned prefix before ownership transfers to the caller. For a bulk drain, the implementation needs a state that remains correct if iteration stops early, the consumer panics, or a destructor panics. The safest design is the one whose explicit state always describes what cleanup still owns.
Choose the least manual construction protocol
| Design | Best fit | Main benefit | Main obligation or cost |
|---|---|---|---|
ordinary [T; N] expression or safe array API |
every element can be built in safe code | compiler owns initialization and cleanup | may not fit foreign out-pointers or custom partial state |
Vec<T> with reserved capacity |
runtime length or heap allocation is acceptable | mature length/capacity and panic-safe cleanup | allocation, capacity policy, possible relocation |
[MaybeUninit<T>; N] plus prefix count |
fixed inline capacity and sequential construction matter | no heap allocation; precise bounded state | unsafe conversion and explicit drop invariant |
| per-slot state or bitmap | completion is genuinely out of order | represents non-prefix initialization | more state, branches, review surface, and cleanup paths |
ManuallyDrop<T> |
a narrow ownership transfer must suppress one automatic drop | preserves an already valid T in place |
double-drop and post-drop-access hazards |
Start with ordinary safe construction. Move to Vec when runtime size and allocation fit. Use MaybeUninit only when the state “not yet a T” is essential to the representation or boundary. Use ManuallyDrop only when the state is “a valid T whose automatic destruction must be suppressed.” Those types are not substitutes.
Operational consequences extend beyond undefined behavior
Initialization bugs can appear as allocator corruption far from the unsafe block, intermittent double frees, leaked file descriptors during repeated failures, or secrets retained in a long-running process. Observability should therefore attach to the boundary without logging raw uninitialized or secret-bearing memory.
For a high-volume parser or object pool, useful counters include construction attempts, fallible initialization failures, pool exhaustion, and cleanup-path executions. Avoid instrumentation callbacks while a representation is temporarily invalid; logging may allocate, panic, re-enter code, or observe the object. Restore a valid cleanup state before calling general-purpose infrastructure.
Security review should ask whether lengths or initialized-set metadata can be influenced by untrusted input. A corrupted len is not merely an out-of-range index: it can make Drop run destructors on arbitrary storage or make a safe slice expose invalid values. Keep counters private, validate external lengths before allocating or iterating, and use checked arithmetic for byte counts.
Portability review should reject assumptions about padding, enum layout, pointer null representation, or field order unless an explicit representation guarantee supplies them. Compatibility review should note the Rust version for every chosen MaybeUninit convenience method and retain an MSRV-tested alternative. The fixture uses APIs available on Rust 1.85.0; its locked checks pass on Rust 1.93.1, while the declared MSRV still requires a separate verification environment.
Exercise: defend a fixed-capacity constructor
Implement PacketBatch<T, const N: usize> whose fallible constructor invokes a caller-provided function for each index and returns [T; N] only after all elements succeed.
Your deliverable must include:
- a representation invariant that distinguishes storage, valid values, and owned values;
- code that compiles on Rust 1.85.0 in Edition 2024 and uses no nightly initialization APIs;
- a state trace for success, error at index three, panic at index three, and successful ownership transfer;
- a cleanup proof showing why every constructed element is dropped exactly once;
- tests with a drop counter for all four paths, including
N = 0; - an explanation of why
assume_init_mutis not an initialization method; - a rejected zero-initialization proposal for a type containing
NonZeroU32andString; - a decision between a prefix counter and bitmap, with the construction order that supports it;
- a
panic = "abort"operational note; - a safety-case table naming each unsafe operation, its preconditions, its evidence, and what the tests cannot prove.
Reject the design if it creates a reference to an uninitialized T, increments the initialized count before a successful write, exposes a manually dropped value through a safe trait implementation, assumes zero is a universal default, or treats a passing Miri run as proof of soundness.
Initialization review questions
- At every program point, can the representation identify exactly which slots contain valid
Tvalues? - Does state advance only after the operation that establishes validity completes?
- Can an error, panic, cancellation boundary, callback, or destructor observe a state the cleanup path cannot describe?
- Does every extraction remove the value from the old owner’s drop set before creating a new owner?
- Are
MaybeUninitandManuallyDropused for their distinct jobs? - Does any safe reference arise before the pointee is valid, aligned, live, and appropriately aliased?
- Are zero and padding assumptions tied to documented representation guarantees?
- Can derived traits or public fields access a value after manual destruction?
- Is explicit initialization metadata private and included in unsafe change review?
- Do tests count drops on success, failure, and panic without deliberately executing undefined behavior?
The initialized-prefix model gives Chapter 66’s safety case a concrete state variable. It also exposes the next layer of proof. A valid T in memory is not enough if the pointer used to reach it came from the wrong allocation, is out of bounds, is misaligned, or creates a reference with an unsupported aliasing claim. Chapter 68 separates those pointer obligations line by line.
Sources and version note
The Rust Reference’s undefined-behavior chapter defines producing invalid values as undefined behavior, lists type-directed validity requirements, outlines aliasing constraints, and explicitly marks parts of the model as evolving. The standard library’s MaybeUninit documentation defines the initialization invariant, layout contract, array and field construction patterns, padding caveats, and ownership behavior of its methods. The ManuallyDrop documentation defines destructor suppression and documents public-generic, derived-trait, post-drop-access, and Box interaction hazards. The Rustonomicon’s uninitialized-memory discussion provides supporting unsafe-code guidance.
These sources were reviewed on 2026-07-21. The locked memory-contracts-lab fixture passed formatting, checks, tests, and Clippy on Rust 1.93.1; Rust 1.85.0 remains its declared MSRV but was not locally available for a fresh run. The Reference and library documentation remain authoritative over this explanation.
Continue reading
Full table of contents