nichy is a visualizer for Rust type memory layouts. It uses rustc's
internal layout engine to show you exactly how your types are arranged in memory,
including field ordering, padding bytes, alignment, and niche optimization.
Every value in Rust occupies a fixed number of bytes determined by its type. The compiler decides the size (total bytes), alignment (what byte boundary the value must start on), and field order for each type.
CPUs access memory most efficiently when values sit at addresses that are multiples of
their alignment. A u32 has alignment 4, so it should start at an address
divisible by 4. A struct's alignment is the maximum alignment of its fields.
The compiler inserts padding bytes between fields (and after the last field) to satisfy alignment constraints. These bytes exist in memory but carry no data.
struct Padded {
a: u8, // 1 byte
b: u64, // 8 bytes, align 8
c: u8, // 1 byte
}
Naively, this would be 10 bytes. But b needs 8-byte alignment, so the
compiler inserts 7 bytes of padding after a. The struct ends up
24 bytes under repr(C).
Rust's default (repr(Rust)) layout is free to reorder fields. The
compiler rearranges this to [b, a, c, padding] for a total of
16 bytes, which saves 8 bytes by grouping small fields together.
| Attribute | Behavior |
|---|---|
#[repr(Rust)] | Default. Compiler may reorder fields, add padding. Layout is not stable across compiler versions. |
#[repr(C)] | Fields laid out in declaration order, with C-compatible padding rules. Deterministic and stable. |
#[repr(transparent)] | Single-field struct has the same layout as that field. Used for newtypes. |
#[repr(packed)] | Remove padding (alignment = 1). Can cause unaligned access; fields may not be borrowable. |
#[repr(align(N))] | Force minimum alignment to N. Can be combined with repr(C). |
#[repr(u8)], etc. | For enums: sets the discriminant integer type explicitly. |
Enums need to store which variant is active (the discriminant) alongside the variant's data. The compiler uses two strategies:
In the straightforward approach, the compiler reserves extra bytes for a tag that records which variant is active. The tag size depends on how many variants the enum has: 1 byte covers up to 256 variants, 2 bytes cover more, and so on.
enum Shape {
Circle(f64), // radius
Rect(f64, f64), // width, height
Point,
}
3 variants, so the tag is 1 byte. The largest variant, Rect, holds
two f64s = 16 bytes. Total size: 24 bytes
(1 tag + 7 padding + 16 data).
This is the optimization that gives nichy its name. Some types have invalid bit patterns, meaning values the type can never hold. These unused patterns are called niches, and the compiler can repurpose them to encode the discriminant, eliminating the extra tag entirely.
A niche is a range of bit patterns that a type guarantees it will never use as a valid value. The compiler tracks niches through the type system and exploits them when building enum layouts.
| Type | Niche | Reason |
|---|---|---|
bool | 254 niches (values 2..=255) | Only 0 and 1 are valid |
&T, &mut T | 1 niche (value 0) | References are never null |
NonZeroU32, etc. | 1 niche (value 0) | Guaranteed non-zero by construction |
Box<T>, NonNull<T> | 1 niche (value 0) | Non-null pointers |
char | Niches above 0x10FFFF* | Unicode scalar values cap at U+10FFFF |
Ordering | 253 niches | Only -1, 0, 1 are valid |
Niches propagate upward through structs. If a struct contains a
bool, the struct itself exposes those 254 niches to any enum wrapping it.
Option<&T>type Ref = Option<&u64>;
A reference &u64 is 8 bytes and is never null. Option
needs to distinguish Some from None, which is only
2 variants, so it needs just 1 niche value.
The compiler stores None as the all-zeros bit pattern (a null pointer),
and Some(r) as the raw pointer value of r, with no extra
tag byte needed. As a result, Option<&u64> is
8 bytes, the same size as a bare &u64.
When a type has more niches than an enum needs, the extras are remaining
niches that propagate further outward. For example, bool
has 254 niches. Option<bool> uses one (storing None
as the byte value 2), leaving 253. So
Option<Option<bool>> consumes one more and is still just
1 byte. An enum with up to 254 data-less variants wrapping a
bool can fit in a single byte the same way.
This stacking works as long as niches remain. Types with only a single niche,
like &T, support one layer of Option at no cost,
but Option<Option<&T>> exhausts the supply and
requires a tag.
u32 or f64
use all their bit patterns, so wrapping them in Option requires a tag byte.#[repr(C)] or #[repr(u8)] enums.
Explicit representation attributes force a predictable tagged layout.
Niche optimization is one of Rust's most impactful zero-cost abstractions. It means
Option<Box<T>>, Option<&T>, and
Option<NonZeroU32> are all the same size as the inner type.
This removes the performance penalty for using Option instead of nullable
pointers, making safe APIs free at the machine level.
The optimization is applied transitively. A
Vec<Option<&T>> stores its elements at 8 bytes each
(on 64-bit), the same density as a Vec<*const T> with a manual
null convention.
Niche optimization is well-known among Rust programmers, but its limitations are often misunderstood. Here are cases where it might seem like niche optimization should help, but doesn't.
When the compiler uses a niche value to represent a variant, it uses that
specific bit pattern as the discriminant. This only works for
variants that carry no data of their own, or whose data fits elsewhere in
the enum's payload
(#46213). In the common case, such as Option::None,
the niche-encoded variant has no fields at all, which is why the
optimization works so cleanly.
enum Either {
Left(NonNull<u8>),
Right(NonNull<u8>),
}
Both NonNull fields have a niche at null. But if the compiler
used null to mean "this is Right", there would be no room
to store Right's actual pointer value because the null pattern
occupies the space where that pointer would need to go. The enum
requires a tag: 16 bytes, not 8.
Compare with Option<NonNull<u8>>, where
None carries no data: null represents it perfectly.
8 bytes, same as a bare NonNull<u8>.
Note that pointers are word-aligned, so their low bits are always zero,
yet the compiler does not pack discriminants into those spare
bits either. The niche system tracks value-range
constraints ("this field is never zero") rather than bit-level
properties derived from alignment. The only niche each
NonNull exposes is the single null value.
Struct padding exists for alignment but is not tracked as niche space. Even when a struct has trailing padding that could physically hold a discriminant, the compiler doesn't repurpose it (#70230, #109555). Niches come from type-level value constraints (like "references are never null") rather than from incidental unused bytes in the memory layout.
type A = Option<WithNiche>;
type B = Option<WithoutNiche>;
struct WithNiche {
ptr: NonNull<u64>,
flag: u8,
// 7 bytes of trailing padding
}
struct WithoutNiche {
ptr: *mut u64,
flag: u8,
// 7 bytes of trailing padding
}
Both structs are 16 bytes with 7 bytes of trailing padding.
Those 7 bytes won't be used for Option's discriminant
in either case. But WithNiche has a niche from
NonNull (the null pointer), so
Option<WithNiche> stays at 16 bytes.
WithoutNiche has no niche, so Option<WithoutNiche>
grows to 24 bytes.
Even when an enum's variants contain types with usable niches, the compiler only uses niche encoding when it makes the overall layout smaller. If the discriminant already fits in alignment padding, niche encoding produces the same size and the compiler picks a tagged layout instead. Whether niche optimization applies can therefore depend on the target triple.
enum Expr {
Literal(f64),
BinOp { op: char, lhs: Box<f64>, rhs: Box<f64> },
Neg(Box<f64>),
}
On x86_64, Box is 8-byte aligned. The
BinOp variant has two 8-byte pointers and a 4-byte
char, totalling 24 bytes after
alignment. The 4-byte slot holding op has 3 unused bytes
next to it, which fit a 1-byte discriminant. Niche encoding can't
shrink the enum below 24 bytes either, so the compiler emits a
tagged layout at
24 bytes.
On i686, Box is 4-byte aligned and
BinOp is 12 bytes (three 4-byte slots,
no padding). A tagged layout would grow to 16 bytes
to fit a 4-byte discriminant. Niche encoding places the discriminant
in char's unused values above U+10FFFF* and
keeps the enum at 12 bytes, 4 bytes smaller, marked
niche optimized.
The compiler tracks niches as value ranges that a type guarantees it will never use. This is a type-level property rather than a layout-level one:
NonNull<T> → value 0 is invalid (1 niche), regardless of T's alignmentbool → values 2..=255 are invalid (254 niches)&T → value 0 is invalid (1 niche)If you want to verify whether niche optimization applies to your types, paste them into the visualizer. A niche optimized badge means the compiler found usable niches; a tagged badge means it fell back to an explicit discriminant.
The authoritative specification for how Rust lays out types in memory, covering
repr(Rust), repr(C), primitive representations, and alignment rules.
Deep dive into how the compiler identifies niches and encodes enum discriminants, with examples traced through LLVM IR and rustc source.
Practical guide to repr(Rust), repr(C), and other representations,
oriented toward unsafe code and FFI.
Documents the guaranteed null-pointer optimization for Option<&T>,
Option<Box<T>>, and similar types.
Visual walkthrough of how Rust's common types (structs, enums, references, trait objects) are laid out in stack and heap memory.
Actionable advice on shrinking types to improve cache performance, including
#[repr(u8)] enums, Box-ing large variants, and field reordering.
How the Rust compiler reuses tag bits from nested enums to reduce memory overhead, going beyond the well-known niche optimization.
* The surrogate range 0xD800..=0xDFFF is also invalid for char, but rustc
currently does not use those values as niches.