Primitive Types and Memory Layout

27 min read
On this page

Understanding layout requires distinguishing three things: which values a type allows, what storage method this compilation uses, and what representations the language or standard library guarantees. A size_of output can help observe the current target, but not all observations should be treated as cross-version ABI guarantees.

Size Does Not Equal Alignment

TypeSizeNotes
u8/i8, u16/i16, u32/i32, u64/i64, u128/i1281, 2, 4, 8, 16 bytes respectivelyAlignment still depends on the target
f32, f644, 8 bytesValues like NaN affect comparison semantics
bool1 byteValid values are only false/true, not arbitrary u8
char4 bytesUnicode scalar values, not arbitrary u32
usize/isizeSize corresponding to target address widthNot suitable as cross-platform file fields
()0 bytes, alignment 1Zero size does not mean all ZSTs have alignment 1

Fixed-width integers have fixed sizes, but alignment does not necessarily equal size; for example, on some 32-bit targets, u64 has an alignment of 4. Pointers to Sized types have the same size and alignment as usize; metadata for DST pointers is another layer of representation. Type Layout

char is a Unicode code point excluding the surrogate range, i.e., a Unicode scalar value. What a user sees as a single grapheme may consist of multiple scalar values; 'あ' occupies 4 bytes as a char but 3 bytes as UTF-8 text. See Strings and Paths for string details.

Integer Overflow: Choose Operations by Intent

Whether standard arithmetic checks for overflow is controlled by compilation configuration and cannot be strictly divided by debug/release names. When a predictable strategy is needed, directly call the checked, wrapping, saturating, or overflowing methods:

fn main() {
    assert_eq!(255u8.checked_add(1), None);
    assert_eq!(255u8.wrapping_add(1), 0);
    assert_eq!(255u8.saturating_add(1), 255);
    assert_eq!(255u8.overflowing_add(1), (0, true));
    assert_eq!(i32::MIN.checked_div(-1), None);
    assert_eq!(10i32.checked_div(0), None);
}

Integer division by zero and signed minimum divided by -1 also need consideration; do not assume that disabling addition overflow checks makes all integer operations safely wrap around. Errors in constant evaluation can also be diagnosed at compile time. Operators

Numeric Conversion: Representable Range Is Part of the Type Contract

as is not a check to "confirm the value fits." Narrowing integers truncates high bits, and signed/unsigned conversions have explicit bit interpretation rules; if file length comes from u64 but the native type uses usize, perform a fallible conversion first, then check business and allocation limits. Being able to convert to usize does not mean allocating that much memory is allowed.

fn main() {
    let count = 300u16;
    assert_eq!(count as u8, 44);
    assert!(u8::try_from(count).is_err());
    assert_eq!(u32::from(255u8), 255);
    assert_eq!((-1i8) as u8, 255);
    // Numeric to byte conversion uses explicit endianness, not dependent on current CPU.
    let bytes = 300u32.to_be_bytes();
    assert_eq!(bytes, [0, 0, 1, 44]);
    assert_eq!(u32::from_be_bytes(bytes), 300);
}

Floating-point numbers also have valid values that do not satisfy ordinary mathematical intuition: NaN is not uninitialized memory, but a valid floating-point value; partial_cmp may return None. If sorting uses total_cmp, it uses a specified total order, which is not exactly the same semantics as ordinary ==; for example, it distinguishes positive zero from negative zero. Whether NaN, infinity, or negative zero are allowed should be defined separately at business boundaries. Numeric Conversion, f64::total_cmp

Struct Layout: Why Field Offsets Require Padding

aPadding 3Bb: 4Bc: 2BPadding 2B01481012repr(C) Example: Padding Between Fields and Tail PaddingThis diagram assumes u32 alignment is 4 and u16 alignment is 2; other targets must calculate according to their own ABI.
use std::mem::{align_of, offset_of, size_of};

#[repr(C)]
struct Header { a: u8, b: u32, c: u16 }

fn main() {
    println!("size={} align={} offsets={},{},{}",
        size_of::<Header>(), align_of::<Header>(),
        offset_of!(Header, a), offset_of!(Header, b), offset_of!(Header, c));
    if align_of::<u32>() == 4 && align_of::<u16>() == 2 {
        assert_eq!(size_of::<Header>(), 12);
        assert_eq!(offset_of!(Header, b), 4);
        assert_eq!(offset_of!(Header, c), 8);
    }
}

repr(C) aligns each field in declaration order and then pads the total size to the struct's alignment. Tail padding ensures that the next struct in an array is also aligned. The default repr(Rust) does not guarantee field declaration order, nor does it promise to always choose the smallest layout.

repr(C) is not a serialization format: padding bytes may be uninitialized, endianness and target ABI still differ, and fields themselves may not be C-compatible types. Do not simply read the entire struct as bytes and treat it as a safe protocol encoding.

Deriving Array Stride and Field Reordering Using Offsets

For the Header in the diagram, a occupies offset 0; b requires 4-byte alignment, so it starts at 4; c follows at offset 8. The last field ends at 10, and since the struct's maximum alignment is 4, the total size is padded to 12. The second element of the array starts at 12, and its b is located at 16, still satisfying alignment.

If you are allowed to change the field declaration order of repr(C), arranging fields as b, c, a would result in offsets 0, 4, 6 under these alignment assumptions, with a total size of 8. It reduces space but simultaneously changes the ABI and field offsets, so you cannot quietly optimize already published external interfaces this way.

Outer repr(C) does not recursively rewrite the representation of internal Rust structs. Two types having the same size and alignment does not mean they have the same set of valid values, drop behavior, or calling ABI; these conditions must be checked separately. Rust Layout Rules

What repr(transparent), align, and packed Change

RepresentationEffectConclusions That Cannot Be Derived
repr(C)Organizes fields according to corresponding C layout rulesAll internal fields automatically become C ABI types
repr(transparent)Wrapper satisfying constraints inherits the layout and ABI of the wrapped fieldAllows wrappers with arbitrary numbers of fields, or implies identical business semantics
repr(align(N))Increases the type's alignment requirementThe actual object definitely occupies a fixed hardware cache line
repr(packed) / packed(N)Reduces alignment constraints for fields and structsOrdinary references can point to misaligned fields

packed is most prone to implicit borrowing issues: reading a field by value may be legal, but creating &packed.field creates an invalid misaligned reference, and some formatting or assertion macros also implicitly borrow. Copy to a local variable before borrowing, or use &raw const and read_unaligned when pointer reading is truly needed, while satisfying initialization, range, and ownership requirements.

#[repr(C, packed)]
struct Packed { kind: u8, count: u32 }

fn main() {
    let packet = Packed { kind: 1, count: 300 };
    let copied = packet.count; // Read Copy field by value, does not create &u32.
    assert_eq!(copied, 300);
    let ptr = &raw const packet.count;
    // SAFETY: ptr points to a still-living and initialized u32 field; read_unaligned does not require alignment.
    // u32 is Copy, so reading does not introduce double-free or other ownership duplication issues.
    let read_back = unsafe { ptr.read_unaligned() };
    assert_eq!(read_back, 300);
    assert_eq!(packet.kind, 1);
    assert_eq!(std::mem::size_of::<Packed>(), 5);
}

This only explains the impact of packed on local layout and cannot replace protocol decoding: integers in fields still follow host numeric representation, and external data may still be illegal or insufficient. read_unaligned only relaxes alignment requirements; it does not make arbitrary addresses, uninitialized values, or non-Copy ownership copies safe. read_unaligned

See fields and padding byte by byte

Rounding upward follows from address constraints: each field starts at a multiple of its alignment, and array stride is a multiple of aggregate alignment. Dots are padding, not data or guaranteed initialized bytes. Reordering can remove dots while changing the external layout.

Preparing the visual
See fields and padding byte by byte

repr(C) aligns each field upward and pads the final size to aggregate alignment; reordering changes the ABI.

Array Parameters: Separate Ownership Semantics from Actual Copies

The length of [T; N] participates in the type, the layout is contiguous, and the size is size_of::<T>() * N. Arrays do not inherently belong to the stack: they can also exist in Box, static storage, or other objects.

Arrays are Copy when T: Copy; passing non-Copy arrays like [String; N] by value moves ownership. The compiler may optimize the machine-level movement, but you cannot describe move as "element-by-element Clone" or guarantee that a full array memcpy is always executed.

When read-only access to data of arbitrary length is needed, &[T] is usually convenient; when handing an array to the receiver to manage, passing by value has clear meaning. The choice depends first on ownership, and then on measuring the overhead of passing large values.

ZSTs Still Have Types, Alignment, and Behavior

Zero-sized types do not need to occupy bytes for field data, but they can have methods, Drop behavior, and alignment requirements. Multiple ZSTs may have the same address, so you cannot use addresses to identify logical objects.

use std::sync::atomic::{AtomicUsize, Ordering};
static DROPS: AtomicUsize = AtomicUsize::new(0);
struct Marker;
impl Drop for Marker {
    fn drop(&mut self) { DROPS.fetch_add(1, Ordering::Relaxed); }
}
fn main() {
    assert_eq!(std::mem::size_of::<Marker>(), 0);
    { let _markers = [Marker, Marker]; }
    assert_eq!(DROPS.load(Ordering::Relaxed), 2);
}

Therefore, the statement "ZSTs do not exist at runtime at all" is incorrect. Vec<ZST> still has length and per-element drop semantics, even though element storage does not require typical non-zero allocations.

Niches: Leveraging Unused Representations of Inner Types

References cannot be null, NonZeroU32 cannot be 0; these unused representations can be used to encode None for Option. The standard library explicitly guarantees that Option for a set of types has the same size, alignment, and calling ABI as the original type; rely on this list. Option Representation Guarantees

use std::mem::size_of;
use std::num::NonZeroU32;
fn main() {
    assert_eq!(size_of::<Option<&u8>>(), size_of::<&u8>());
    assert_eq!(size_of::<Option<NonZeroU32>>(), size_of::<NonZeroU32>());
    println!("Option<bool> observed size: {}", size_of::<Option<bool>>());
    println!("nested Option reference observed size: {}", size_of::<Option<Option<&u8>>>());
}

Option<bool> can often leverage extra bit patterns for compression, but do not fix the specific byte value of None to 2. Nested Options need to distinguish between outer None, Some(None), and Some(Some(_)), so you cannot directly reuse the existing null pointer representation of the inner type.

Representation optimization does not mean there is no need to check for the presence of a value, nor does it allow creating invalid bool or invalid references. Memory size, control flow cost, and valid value constraints should be understood separately.

External Bytes and Valid Values: Do Not Create Invalid Values First and Then Check

External BytesPossibly invalid inputDecode and validateLength, Endianness, Range, UTF-8Valid Rust ValueType Invariants Are SatisfiedFrom Bytes to Rust Values: Layout Match Must Be Followed by Valid Value Constraints256 Bit Patterns of u8 ≠ Two Valid Values of boolMaybeUninit allows storing uninitialized storage, but does not exempt the need to prove validity when calling assume_init.

bool occupies 1 byte, but only 0 and 1 are valid representations; char occupies 4 bytes, but surrogate code points are not valid chars. References have constraints beyond non-null, including alignment, lifetime, and accessible memory. Using unsafe to forcibly cast bytes to these types first, and then checking if they are legal, places the check too late. Undefined Behavior and Valid Values

A more direct approach is to start from the weakest promise of bytes and build stronger types item by item. The following defines a 5-byte wire record: the first byte is 0/1 enabled, followed by four bytes for big-endian uint32 count. Its wire length is defined by the protocol and is unrelated to the size of any Rust struct.

#[derive(Debug, PartialEq)]
struct Record { enabled: bool, count: u32 }

fn decode(input: &[u8]) -> Result<Record, &'static str> {
    if input.len() != 5 { return Err("length"); }
    let enabled = match input[0] {
        0 => false,
        1 => true,
        _ => return Err("invalid bool"),
    };
    let count = u32::from_be_bytes(input[1..5].try_into().map_err(|_| "length")?);
    if count > 1000 { return Err("business limit"); }
    Ok(Record { enabled, count })
}
fn main() {
    assert_eq!(decode(&[1, 0, 0, 1, 44]), Ok(Record { enabled: true, count: 300 }));
    assert_eq!(decode(&[2, 0, 0, 0, 0]), Err("invalid bool"));
    assert_eq!(decode(&[1, 0]), Err("length"));
    assert_eq!(decode(&[1, 0, 0, 3, 233]), Err("business limit"));
    assert_eq!(char::from_u32(0xd800), None);
}

MaybeUninit<T> is used to represent storage where a valid T is not yet guaranteed. Only after all necessary fields are correctly initialized can you obtain T; all-zero memory is not a valid value for all Ts, especially for references, NonZero, and certain enums. The size and alignment relationship between MaybeUninit<T> and T does not mean Option<MaybeUninit<T>> can reuse the original niche optimization of T. MaybeUninit

When reading a piece of layout code, you can ask in sequence: Is it only observing the current target? Does it rely on promised representations? Are valid values established? Are ownership requirements met? Is cross-platform wire encoding still needed? Having clear answers to these five questions is more reliable than proving safety solely by stating "memory sizes are the same."