Coding by Hand
Rust home

Stack and Heap, Made Explicit

Your kitchen has two places to put things. The counter is a neat stack of clean plates — every new plate lands on top, the bottom plate stays put until you have lifted every plate above it, and the height of the stack is something you can read off in one glance. The pantry is the other place. It has dozens of shelves at random heights, food goes wherever it fits, and the only way you ever find the cinnamon again is the sticky note on the counter that says "cinnamon — third shelf, behind the rice." Every running Rust program has these same two places. The counter is called the stack. The pantry is called the heap. Every value you create lives in one of them, and the difference is the whole reason Rust knows when to free your memory.

A kitchen counter with a tidy stack of plates next to a pantry of scattered shelves — the two places every Rust value lives.
A kitchen counter with a tidy stack of plates next to a pantry of scattered shelves — the two places every Rust value lives.

The stack got its name from a man named Friedrich Bauer at the Technical University of Munich in 1955. He was writing a compiler for a language called Algol and ran into the problem of how to handle nested function calls — when f calls g which calls h, each one needs its own scratch space for variables, and when h finishes the space has to vanish so g's space is on top again. Bauer realized the call pattern was exactly last-in-first-out, the same shape as a pile of plates, and he wrote a paper proposing the hardware should have a single growing region of memory that worked that way. Every CPU built since the 1960s has a stack pointer register for this reason. The heap came in alongside — when Algol added the ability to allocate values whose lifetime did not match a function call, the runtime needed a second region of memory where blocks could live at arbitrary addresses for arbitrary durations. Donald Knuth named it the heap in 1968 because the free blocks looked like an unorganized pile compared to the tidy stack.

The first thing to internalize is that every value in Rust has a fixed footprint on the stack. Even a value that lives in the pantry has a sticky note on the counter — that note has a fixed size, even if the food behind it does not. Rust ships a function called std::mem::size_of that tells you exactly how big the stack slot is for any type, and the answer is the same on every machine of the same word size.

fn show_sizes() {
    println!("--- sizes (bytes on the stack) ---");
    println!("i32         : {}", size_of::<i32>());
    println!("i64         : {}", size_of::<i64>());
    println!("f64         : {}", size_of::<f64>());
    println!("bool        : {}", size_of::<bool>());
    println!("&i32        : {}", size_of::<&i32>());
    println!("Box<i32>    : {}", size_of::<Box<i32>>());
    println!("Vec<i32>    : {}", size_of::<Vec<i32>>());
    println!("String      : {}", size_of::<String>());
}
--- sizes (bytes on the stack) ---
i32         : 4
i64         : 8
f64         : 8
bool        : 1
&i32        : 8
Box<i32>    : 8
Vec<i32>    : 24
String      : 24
--- box ---
on_stack value : 42
on_heap value  : 42
on_stack size  : 4 bytes (the number itself)
on_heap size   : 8 bytes (the pointer)
--- array vs vec ---
[i32; 5] stack contents : [10, 20, 30, 40, 50]
Vec<i32> heap contents  : [10, 20, 30, 40, 50]
[i32; 5] stack size     : 20 bytes (5 ints inline)
Vec<i32> stack size     : 24 bytes (ptr + len + cap)
--- string size ---
short text       : "hi"
long text        : "the cat sat on the mat and watched the rain"
short stack size : 24 bytes
long stack size  : 24 bytes
short heap bytes : 2
long heap bytes  : 43

Read the first block of the output. An i32 is 4 bytes — a 32-bit integer fits inline on the stack with no pointer involved. A bool is 1 byte even though it only carries one bit, because the CPU addresses memory a byte at a time. A reference like &i32 is 8 bytes on a 64-bit machine because it is an address, and addresses on a 64-bit machine are 64 bits. Box<i32> is also 8 bytes — same reason, it is just an owning pointer. The interesting line is String, which is 24 bytes regardless of whether the actual text is two characters or two megabytes. A String on the stack is a sticky note with three things on it: a pointer to the text in the pantry, the number of bytes currently used, and the number of bytes the pantry shelf actually holds. Three 8-byte numbers, 24 bytes total. The text itself sits in the pantry where the size can change without disturbing the note.

A function's stack frame holds fixed-size slots; values too big to fit live as blocks scattered across the heap.
A function's stack frame holds fixed-size slots; values too big to fit live as blocks scattered across the heap.

The cheapest way to put a value in the pantry on purpose is Box::new. You hand Box a value, it asks the allocator for a shelf big enough to hold that value, copies the value onto the shelf, and gives you back a sticky note. The note is 8 bytes on the counter. The value is on the shelf. When you want to read the value, you write *box_name — the star is the instruction "follow the sticky note."

fn show_box() {
    let on_stack: i32 = 42;
    let on_heap: Box<i32> = Box::new(42);
    println!("--- box ---");
    println!("on_stack value : {on_stack}");
    println!("on_heap value  : {}", *on_heap);
    println!("on_stack size  : {} bytes (the number itself)", size_of::<i32>());
    println!("on_heap size   : {} bytes (the pointer)", size_of::<Box<i32>>());
}

Both values print 42. From the outside they look identical. The difference is where the bytes live. The stack version stores the number 42 directly in a slot on the counter, four bytes wide, and disappears the moment the function returns. The boxed version stores a pointer on the counter — something like 0x7ffeefbff5a8, an address into the pantry — and the actual 42 lives at that address. When the box goes out of scope, Rust runs the box's destructor, which calls the allocator's free function and hands the shelf back. No garbage collector ever sees this. The compiler knew at compile time where the free belonged.

Knowing both options exist, why pick the pantry at all? Because some shapes do not fit on the counter. A stack frame has a size the compiler must know at compile time, and there are cases where you cannot know — most obviously, when you want a collection that grows. That is what Vec<i32> is. Lay it next to a fixed array and the trade is visible.

fn show_array_vs_vec() {
    let on_stack: [i32; 5] = [10, 20, 30, 40, 50];
    let on_heap: Vec<i32> = vec![10, 20, 30, 40, 50];
    println!("--- array vs vec ---");
    println!("[i32; 5] stack contents : {on_stack:?}");
    println!("Vec<i32> heap contents  : {on_heap:?}");
    println!("[i32; 5] stack size     : {} bytes (5 ints inline)", size_of::<[i32; 5]>());
    println!("Vec<i32> stack size     : {} bytes (ptr + len + cap)", size_of::<Vec<i32>>());
}

Both lines hold the numbers 10 through 50. The array has all 5 integers parked inline on the stack — 20 bytes, end to end, no pointer involved. The vector has 24 bytes on the stack — the same three-number sticky note pattern as String, because a Vec is a string of integers instead of characters. The actual numbers live in the pantry. When you push a sixth number, the array refuses because its size was baked in, and the vector calls the allocator for a bigger shelf, copies the old numbers across, and updates the pointer on its note. The cost of growth is the price you pay for not knowing the size in advance.

Box<i32> is an 8-byte pointer on the stack that owns a single integer sitting in a heap allocation.
Box<i32> is an 8-byte pointer on the stack that owns a single integer sitting in a heap allocation.

The last block makes the sticky-note rule unmissable. Two String values, one with 2 characters and one with 43. The stack footprint is the same.

fn show_string_size() {
    let short = String::from("hi");
    let long = String::from("the cat sat on the mat and watched the rain");
    println!("--- string size ---");
    println!("short text       : {short:?}");
    println!("long text        : {long:?}");
    println!("short stack size : {} bytes", size_of::<String>());
    println!("long stack size  : {} bytes", size_of::<String>());
    println!("short heap bytes : {}", short.len());
    println!("long heap bytes  : {}", long.len());
}

short and long both reserve 24 bytes on the counter. The byte counts on the actual text — 2 and 43 — are reads of the len field on the sticky note, telling you how much of the pantry shelf is in use. A String that holds the complete works of Shakespeare would still be 24 bytes on the stack. The pantry shelf would be five megabytes. The compiler does not need to know that number to lay out the stack frame, because the stack frame only holds the note.

One question the output answers if you stare at it — why are Vec<i32> and String both exactly 24 bytes, even though one holds numbers and the other holds characters? Because both are the same three-field struct: pointer, length, capacity. Three 8-byte numbers. The element type only matters to the allocator when it decides how many bytes per element to reserve on the heap. The note on the counter looks the same in both cases.

Now the next bottleneck — if every heap value has exactly one sticky note that owns it, what happens when you write let b = a and try to share that note with another name?