niche.rs about

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.

Rust object layout

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.

Alignment and padding

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.

Example: padding in action · try it
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.

repr(Rust) layout — 16 bytes, align 8 offset size field 0x00 8 b: u64 0x08 1 a: u8 0x09 1 c: u8 0x0a 6 padding

Layout representations

AttributeBehavior
#[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.

Enum layout

Enums need to store which variant is active (the discriminant) alongside the variant's data. The compiler uses two strategies:

Tagged explicit discriminant

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.

Example: tagged enum · try it
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).

Niche optimized zero-cost discrimination

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.

Niche optimization in detail

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.

Where niches come from

TypeNicheReason
bool254 niches (values 2..=255)Only 0 and 1 are valid
&T, &mut T1 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
charNiches above 0x10FFFF*Unicode scalar values cap at U+10FFFF
Ordering253 nichesOnly -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.

The classic example: Option<&T>

Example: zero-cost Option · try it
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.

Option<&u64> — 8 bytes, align 8 (niche optimized) Some(&u64): the pointer value (always non-zero) None: 0x0000000000000000

Multiple niche values

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.

When niche optimization cannot apply

Why this matters

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.

Common misunderstandings

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.

Niches can't encode variant data

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.

Example: two data-carrying variants · try it
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.

Either — 16 bytes, align 8 (tagged) offset size purpose 0x00 8 tag (1 byte) + padding (7 bytes) 0x08 8 variant data: NonNull<u8>

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.

Padding bytes are not niches

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.

Example: padding can't be reused · try it
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.

Niche optimization only applies when it reduces size

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.

Example: target-dependent niche · try on x86_64 · try on i686
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.

What the niche system actually tracks

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:

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.

Related articles

* The surrogate range 0xD800..=0xDFFF is also invalid for char, but rustc currently does not use those values as niches.