Skip to content
← All articles

Enum layout and niche optimization: why Option<Box<T>> is free

Option<Box<T>> is exactly as big as Box<T>, and Option<i32> is twice as big as i32. Both facts come from one mechanism — the niche — and knowing it is the difference between trusting "zero cost" and verifying it.

In the previous item you wrapped a recursive type’s children in Box and were told, in passing, that Option<Box<T>> is “free”. That claim deserves better than a footnote. Here is the mechanism, the guarantee level, and the places where the intuition breaks.

Run this on any machine with a Rust toolchain:

use std::mem::size_of;

fn main() {
    println!("{}", size_of::<Box<i32>>());          // 8
    println!("{}", size_of::<Option<Box<i32>>>());  // 8   <- the surprise
    println!("{}", size_of::<i32>());               // 4
    println!("{}", size_of::<Option<i32>>());       // 8   <- the other surprise
}

Wrapping a Box in Option costs nothing. Wrapping an i32 in Option doubles it. Same wrapper, opposite outcome. Nothing about Option explains that; the explanation is entirely about what the inner type does with its bit patterns.

How an enum is laid out, naively

An enum value must record two things: which variant it is, and that variant’s payload. The naive layout is therefore a discriminant (a small integer tag) followed by enough space for the largest payload, plus padding so every field lands on a legal alignment.

For Option<i32> the naive layout is what you get: one byte of tag, three bytes of padding so the i32 is 4-byte aligned, four bytes of payload — eight bytes total, and align_of is 4. Half the type is bookkeeping.

The niche

Now consider Option<&i32>. A &i32 is a pointer, eight bytes, and it can hold 2⁶⁴ different bit patterns — but not usefully 2⁶⁴, because a reference in Rust is guaranteed never null. The all-zeroes pattern is unreachable. It is a hole in the value’s domain.

A hole like that is called a niche, and the compiler’s layout algorithm hunts for them. If the payload type leaves some bit pattern unused, the enum can store its discriminant inside the payload’s own bits instead of alongside them. So Option<&i32> is laid out as: eight bytes, where all-zeroes means None and anything else means Some(that pointer). No tag. No padding. Eight bytes total, identical to &i32.

This is why Option<Box<T>> is free too — Box carries the same non-null guarantee — and it is why the pattern Option<Box<Node>> is the correct way to spell “maybe a next node” in a linked structure. You get nullability back, at the machine level exactly the null pointer C would have used, but the compiler forces you to check before you dereference.

i32 has no niche. Every one of its 2³² bit patterns is a legal i32. There is nowhere to hide a tag, so the tag needs its own byte, and alignment inflates that byte to four.

💡std::num::NonZeroU32 is a u32 that is statically guaranteed not to be zero. Without running anything, predict size_of::<u32>(), size_of::<Option<u32>>() and size_of::<Option<NonZeroU32>>(). click to reveal

4, 8, and 4.

u32 is four bytes. Option<u32> has no niche to exploit, so it pays a tag byte plus padding to 4-byte alignment: eight. NonZeroU32 has exactly one forbidden pattern — zero — and that is all the compiler needs: Option<NonZeroU32> stores None as zero and is four bytes, the same as the u32 it wraps.

That is precisely why the NonZero* family exists in std. It is not a validation helper; it is a layout helper. Every API that wants “a count, or nothing” and cares about size uses it.

Measured values, 1.95, aarch64

These are real numbers from rustc 1.95.0. Read them as evidence, not as specification — the next section explains the difference.

type size why
Box<i32> 8 one pointer
Option<Box<i32>> 8 null-pointer niche
Option<&i32> 8 same
Option<fn()> 8 function pointers are non-null too
Option<i32> 8 no niche in i32: tag + padding
Option<u8> 2 tag + payload, align 1, so no padding
Option<bool> 1 bool uses 2 of 256 patterns — 254 spare
Option<Option<bool>> 1 still 253 spare; niches nest
Option<Option<Option<bool>>> 1 and keep nesting
Option<char> 4 char is a scalar in 0..=0x10FFFF minus surrogates
Result<(), ()> 1 no payload at all, just a tag
Option<Result<u32, u32>> 8 Result<u32,u32> is 8 with no spare pattern
Option<Vec<u8>> 24 Vec is 24 and its pointer is the niche
Option<String> 24 same

Two of these are worth pausing on.

Option<Option<Option<bool>>> being one byte is the clearest possible demonstration that a niche is a set of unused patterns, not a single flag. A bool occupies one byte but only two of its 256 values are legal. The first Option claims a third value for None. The second claims a fourth. You could nest this a couple of hundred times before the byte fills up.

Option<Vec<u8>> being the same 24 bytes as Vec<u8> matters more than it looks. It means Option<String>, Option<Vec<T>>, Option<Rc<T>> and Option<Box<T>> — the overwhelming majority of “optional owned thing” in real code — are all free. When people say Rust’s Option is zero-cost, this table is what they mean.

The guarantee level, stated precisely

Here is the part most explanations skip, and it is the part that will bite you.

repr(Rust) layout is explicitly unspecified. The Rust Reference says so in as many words. The compiler is free to reorder struct fields, pick a different discriminant size, or change its niche strategy in any release, and it has done all three. Nothing in the table above is promised to you except the following:

The null-pointer optimization is guaranteed and documented: for Option<T> where T is &U, &mut U, Box<U>, fn, NonNull<U>, NonZero* and a few others, Option<T> has the same size and alignment as T, and None is represented as all-zeroes.

That is the whole stable surface. Option<bool> being one byte, Option<char> being four, structs with their fields in declaration order — all implementation details that happen to be true today.

If you need a guaranteed layout, you have to ask for one: #[repr(C)] gives you C’s rules (declaration order, C’s padding), #[repr(u8)] on an enum pins the discriminant type, #[repr(transparent)] promises a single-field wrapper is laid out exactly like its field. Those are the tools for FFI and for on-the-wire formats. Reaching for them “for performance” is almost always a mistake: repr(C) forbids the field reordering that lets repr(Rust) pack your struct tightly, so it frequently makes types larger.

💡A colleague adds #[repr(C)] to a hot internal struct because "explicit layout is faster". What do you tell them? click to reveal

That repr(C) is a constraint, not an optimization, and constraints cannot make the compiler faster — only less free.

repr(Rust) sorts fields to minimise padding. Given struct S { a: u8, b: u64, c: u8 }, repr(Rust) can lay it out as b, a, c and land at 16 bytes. repr(C) must use declaration order: a, seven bytes of padding, b, c, seven more bytes of padding — 24 bytes. Half again as large, and it will also lose niche optimizations across the struct.

repr(C) is correct when the layout is part of an interface: FFI with C, memory-mapped hardware, a struct you transmute or write to disk. Nowhere else.

Where this shows up in your code

Three practical consequences, in rising order of usefulness.

One: prefer Option<Box<T>> over sentinel values. In C you would use a null pointer, or -1, or a magic usize::MAX index, and every reader has to know the convention. Option<Box<T>> costs the same bytes and the compiler will not let you forget the check.

Two: watch the size of your enum’s largest variant. An enum is as big as its fattest variant, always, for every value. An enum Message with one 512-byte variant makes every Message 512 bytes, including the ones that carry a u8. That is a real cost — in Vec<Message>, in every move, in every cache line — and it is the subject of the next item, where clippy will complain about it by name.

Three: Result<T, E> inherits all of this. Result<(), Box<dyn Error>> is one pointer wide, because the Ok variant is empty and the Err variant is a fat pointer with a niche. That is the reason returning Result from a function that usually succeeds costs essentially nothing, and it is worth knowing when someone tells you Rust’s error handling is expensive.

What to carry forward

  • An enum needs a discriminant; a niche is an unused bit pattern in the payload where that discriminant can hide.
  • &T, &mut T, Box<T>, fn, NonNull<T> and NonZero* are non-null, so Option of any of them is free — and this specific case is a documented, stable guarantee.
  • Option<i32> doubles because i32 has no spare patterns. This is not a flaw; there is nowhere to put the tag.
  • Everything else about repr(Rust) layout is unspecified. Measure with size_of if you are curious; do not build a protocol on the answer.
  • #[repr(C)] buys you a promise and costs you field reordering. Use it for interfaces, never for speed.