Struct Layout and Generic Structs
Understand how Rust arranges struct data in memory, how generic type parameters affect layout, and how lifetime parameters interact with struct definitions
Once a struct is defined and you start using it in real code, two deeper questions emerge: how does the compiler actually arrange its fields in memory, and how do you write a single struct that works with different types without duplication? The first question is about memory layout—the physical positioning, padding, and alignment of data. The second is about generics—a way to make structs flexible over the types they hold. Both shape how structs behave at runtime, how much memory they use, and whether they can be shared safely with other languages through FFI.
Memory Layout of Structs
Every value in Rust lives somewhere in memory, and a struct is no exception. Memory layout determines the exact byte-level arrangement of a struct’s fields: their order, the gaps (padding) between them, and the alignment boundaries the whole struct must respect. These details matter for three reasons: performance (smaller, better-aligned structures are faster), correctness when working with raw bytes or unsafe code, and compatibility with C or other languages that expect a specific layout.
The Default Layout: repr(Rust)
By default, Rust uses what’s called the repr(Rust) layout. The compiler is free to reorder fields, insert padding, and adjust alignment in any way that it finds optimal. The only thing you can rely on is that the struct will be correctly aligned for each field’s type; beyond that, all bets are off. This freedom allows the compiler to minimize the total size of the struct by placing larger-aligned fields first and tucking smaller fields into the gaps.
Consider a struct with two u8 bytes and a u32 integer:
struct Mixed {
a: u8,
b: u32,
c: u8,
}
If the compiler kept fields in declaration order, the struct would start with a at offset 0, followed by 3 bytes of padding so that b can sit on a 4-byte boundary, then b at offsets 4–7, then c at offset 8, with the total size rounded up to a multiple of 4—resulting in 12 bytes. Under repr(Rust), the compiler may reorder to b, a, c: b at 0, a at 4, c at 5, size rounded to 8 bytes. That’s a 33% size reduction, and the compiler does it automatically.
No Portable Guarantees:
The default layout can change between Rust compiler versions. If you need to rely on a stable layout—for writing to disk, sending across a network, or FFI—you must opt into a fixed representation.
C Layout with #[repr(C)]
When you annotate a struct with #[repr(C)], the compiler lays out fields exactly in the order they appear, using C’s alignment and padding rules. This is the only way to guarantee a predictable, cross-language-compatible layout. The same Mixed struct under #[repr(C)] will always be 12 bytes on platforms where u32 has 4-byte alignment, because the field order is fixed.
struct Mixed {
a: u8,
b: u32,
c: u8,
}
// The compiler may reorder fields.
// Typical size on 64-bit: 8 bytes (fields reordered to b, a, c)
Use #[repr(C)] only when you need a stable layout—primarily for FFI, but also for any scenario where you must interpret the bytes of the struct externally. It disables the compiler’s size optimization, so keep that trade-off in mind.
FFI Requires C Layout:
Passing a struct with default layout across an FFI boundary is undefined behavior. The foreign code must know the exact field offsets, and only #[repr(C)] provides that guarantee. Even if it seems to work, a compiler update could break it silently.
#[repr(transparent)] for Single-Field Wrappers
A struct with exactly one non-zero-sized field can be marked #[repr(transparent)]. This guarantees that the struct has the identical memory layout and the same ABI (calling convention behavior) as that inner field. It’s the tool for creating newtype wrappers with zero runtime cost.
#[repr(transparent)]
struct Millimeters(u32);
let m = Millimeters(500);
// Millimeters and u32 are guaranteed to have the same size, alignment, and ABI.
Without #[repr(transparent)], a single-field struct still gets the same memory layout as its field (the compiler eliminates the wrapper), but the ABI—how it is passed to and returned from functions—is not guaranteed. If you’re writing a wrapper that will cross an FFI boundary, always use #[repr(transparent)] to ensure foreign code sees the underlying type.
Transparent Wrappers Are Zero-Cost:
When you define #[repr(transparent)] on a newtype, you get both type safety (the wrapper prevents accidental mixing of values) and a performance guarantee—the wrapper compiles away entirely. This is the idiomatic way to build strong typing around primitives like IDs, units, or indices.
Zero-Sized Types in Layout
Rust has types that occupy no space at runtime: the unit type (), empty structs, and marker types like PhantomData. These are zero-sized types (ZSTs) with size 0 and alignment 1. When a ZST appears as a field in a struct, it contributes nothing to the struct’s size or alignment. The compiler treats it as if it were absent.
use std::marker::PhantomData;
struct Tagged<T> {
value: u32,
_marker: PhantomData<T>, // ZST — does not affect layout
}
// The size of Tagged<T> is always 4 bytes, regardless of T.
assert_eq!(std::mem::size_of::<Tagged<String>>(), 4);
Under #[repr(C)], ZST fields are similarly zero-sized and have no effect on offsets. This is important for FFI: you can include PhantomData in a repr(C) struct to tie it to a Rust type without changing the C-compatible layout. The C side can safely ignore those fields.
ZST Guarantees Are Current Behavior:
The interaction of ZSTs with #[repr(C)] is well-defined today, but the Rust reference notes this could change in a future edition. For now, relying on ZSTs having zero size in C layout is sound; keep an eye on official release notes if this affects a long-lived codebase.
Generic Structs
A struct that takes type parameters can hold values of different types without rewriting the entire definition. This is Rust’s mechanism for reusable, type-safe containers: Vec<T>, Option<T>, Result<T, E> all follow this pattern. The same concept applies to your own structs.
Defining a Generic Struct
The syntax mirrors generic functions. After the struct name, you declare one or more type parameters in angle brackets, then use them in the fields:
struct Point<T> {
x: T,
y: T,
}
struct Pair<A, B> {
first: A,
second: B,
}
Point<T> enforces that both x and y share the same type T. Pair<A, B> allows two independent types. You can have as many parameters as you need, though more than two or three often signals that the abstraction needs to be broken down.
When you use a generic struct with concrete types—Point<i32> or Pair<&str, bool>—the compiler performs monomorphization: it generates a distinct version of the struct for each set of concrete type arguments. Each monomorphized version has its own size, alignment, and field layout, determined by the actual types used.
let int_point: Point<i32> = Point { x: 5, y: 10 };
let float_point: Point<f64> = Point { x: 1.0, y: 2.0 };
// These are different concrete types; each gets its own layout.
assert_eq!(std::mem::size_of::<Point<i32>>(), 8); // 2 × 4 bytes
assert_eq!(std::mem::size_of::<Point<f64>>(), 16); // 2 × 8 bytes
Monomorphization and Layout Implications
Because monomorphization creates separate code for each concrete type, the layout of a generic struct is not determined until the type parameters are filled in. This has practical consequences:
- Size and alignment vary per instantiation. A
Buffer<T>holding au8will be 1 byte per element; with au64it becomes 8 bytes. The struct must be generic in functions that operate on it, or you must specify concrete parameters. - Trait bounds are often needed to do anything useful with
T. For layout alone, you don’t need bounds, but methods on the struct that, for example, compare fields will requireT: PartialEq. The layout, however, is always available for anyTthat isSized(the default). - Unsized types require
?Sized. By default,Thas an implicitSizedbound. If you want a struct to hold a type like[u8]ordyn Traitdirectly (which are unsized), you must opt out withT: ?Sized. The resulting struct itself becomes unsized and can only be used behind a pointer likeBox,&, orRc.
struct SliceWrapper<T: ?Sized> {
ptr: *const T, // pointer to unsized data
}
// This works because the struct stores only a pointer.
let w: SliceWrapper<[u8]> = SliceWrapper { ptr: &[1, 2, 3] as *const [u8] };
Generic Structs and FFI
You can combine #[repr(C)] with generic structs for FFI purposes, but each monomorphized type is its own distinct C-compatible struct. For the layout to be well-defined at the FFI boundary, every type parameter must itself have a defined layout—typically a primitive, a repr(C) struct, or a pointer.
#[repr(C)]
struct Handle<T> {
id: u32,
data: *mut T,
}
Here, Handle<T> is C-compatible for any T because *mut T is a pointer of fixed size. If you instead embedded T by value (data: T), the struct would be C-compatible only when T is also repr(C). If you pass such a struct to C code, the foreign definition must match the monomorphized type exactly.
Generic Structs with PhantomData
Sometimes a struct needs to act as if it owns a value of a type without storing it directly—for example, when building custom smart pointers or collections that manage raw pointers. PhantomData<T> is a ZST that tells the type system “this struct logically owns a T” without affecting memory layout. It’s essential for correct variance and drop checking.
use std::marker::PhantomData;
use std::ptr::NonNull;
struct MyBox<T> {
ptr: NonNull<T>,
_marker: PhantomData<T>, // indicates ownership of T
}
impl<T> MyBox<T> {
fn new(value: T) -> Self {
let ptr = unsafe { NonNull::new_unchecked(Box::into_raw(Box::new(value))) };
MyBox {
ptr,
_marker: PhantomData,
}
}
}
MyBox<T> will have exactly the size of a pointer (plus possible alignment padding), regardless of how large T is, because PhantomData adds zero bytes. The field communicates to the compiler that MyBox<T> should be treated as if it contains a T for drop-check and auto-trait reasoning, even though no T lives inside the struct’s own memory.
Missing PhantomData Causes Subtle Bugs:
Omitting PhantomData on a struct that indirectly owns data can lead to incorrect variance, missed drop calls, or the compiler assuming the struct is Send/Sync when it shouldn’t be. If your struct contains a raw pointer to data of type T, include PhantomData<T> unless you have a very specific reason not to.
Structs with Lifetime Parameters
A struct can hold references to data that lives elsewhere. In that case, the struct needs a lifetime parameter to tell the borrow checker how long those references remain valid. Unlike type parameters, lifetimes have no effect on runtime memory layout. They exist purely at compile time and are erased completely before code generation.
How Lifetimes Are Erased
A reference like &'a str is a pointer plus a length at runtime. The lifetime 'a only constrains how long that reference may be used; it does not add any bytes to the struct.
struct Excerpt<'a> {
content: &'a str,
}
let text = String::from("hello");
let ex = Excerpt { content: &text };
// The size is the same as a reference (pointer + length).
assert_eq!(std::mem::size_of::<Excerpt>(), std::mem::size_of::<&str>());
You can use multiple lifetime parameters, and they are all erased the same way. The layout of Excerpt<'a> is identical to Excerpt<'b> for any lifetimes 'a and 'b.
Combining Lifetimes and Generics
A struct can be generic over both a type and a lifetime. This is common when you want a container that borrows data of some type.
struct Container<'a, T> {
value: &'a T,
}
The layout depends on T only insofar as &'a T is a reference, which is always pointer-sized. So Container<'a, i32> and Container<'b, String> have the same size. The lifetime parameter simply ties the struct’s borrow to the scope of the referenced data.
fn create_container<'a, T>(val: &'a T) -> Container<'a, T> {
Container { value: val }
}
This function returns a struct that is valid for as long as the input reference is valid—no extra storage needed. The lifetime 'a is purely a proof to the compiler, not a runtime payload.
What Matters Most
Memory layout, generics, and lifetimes together form the backbone of how structs actually behave in a Rust program. The default layout gives the compiler freedom to optimize size, while #[repr(C)] gives up that freedom in exchange for portability and C compatibility. #[repr(transparent)] sits in between, letting you wrap a type with no runtime cost and a guaranteed ABI mirror. Generics turn one struct definition into many, with each monomorphized version carrying its own concrete layout, while zero-sized markers like PhantomData let you shape the type system without inflating memory. Lifetime parameters are an entirely compile-time scaffolding: they describe ownership and borrowing relationships but leave no trace in the final binary.
When you next reach for a struct, start by asking: does this need to cross a C boundary? If yes, #[repr(C)] is non-negotiable. Is this a thin wrapper around a single value that should be transparent to callers? Reach for #[repr(transparent)]. Do you need to store a variety of types without duplicating logic? Make it generic. And if you’re holding a reference, add the lifetime parameter—it costs nothing at runtime and gives the borrow checker exactly the information it needs.