Skip to content
beginner Phase 2 · Ownership and Move Semantics

Stack and Heap Allocation

See where values live and why Strings allocate while string literals do not.

40m
0 problems
Topic Progress 0%

The Stack: Fixed Sizes, LIFO Frames, Free at Scope Exit

The stack is where Rust puts values whose size is known at compile time and whose lifetime is exactly one scope. It is a LIFO structure: each function call pushes a frame holding its locals and arguments, and returning pops that frame. No allocator is involved — a push or pop is a pointer bump — which is why stack operations are constant-time and why they are the default for everything small.

fn add(a: i32, b: i32) -> i32 {
    let sum = a + b;    // sum lives in add's frame (a slot on the stack)
    sum                 // frame popped on return; the slot is reused by the next call
}

Sizes are known because types are sized: i32 is 4 bytes, f64 is 8, bool is 1, a tuple (i32, f64) is a fixed block, and a struct is the sum of its aligned fields. All of these are Sized and all are stack-friendly. What cannot go on the stack is anything with a runtime-determined size — a string whose length depends on input, a vector that grows. "Unknown at compile time" is precisely the heap's job.

Rust's twist on the classic stack/heap story: the ownership rules from earlier are stack rules. Every local binding owns a stack slot; when the scope ends, the slot is popped and — for owning types — a destructor may run, but the slot itself is reclaimed by the frame popping, not by any allocator call. That is why plain i32 locals are "free": their drop is a no-op and the stack pointer simply moves back.

One layout detail that repays attention: alignment. A value's size is rounded up so its address is a multiple of its alignment, and fields inside a struct are ordered to minimize padding:

struct A { a: u8, b: u32 }   // size 8, not 5: 3 bytes padding after `a`
struct B { b: u32, a: u8 }   // also 8, but the padding sits at the end
assert_eq!(std::mem::size_of::<A>(), 8);

Misordered fields waste memory and can measurably slow array traversal; field reordering is a real optimization interviewers occasionally probe. The stack has two sharp edges worth knowing before you reach for recursion-heavy code: it is small (a few MB per thread by default — Windows 1 MB, Linux 8 MB), so deeply recursive functions overflow it (the infamous thread 'main' has overflowed its stack), and it is per-thread, so stack data is never shared across threads without explicitly moving it. Both limits push genuinely dynamic data to the heap, where String and Box come in.

One correction to the "moves are free" intuition: it is free only for indirection-based types. Moving a value copies whatever bytes are inline in it:

let big: [u64; 1024] = [0; 1024];   // 8 KB, inline on the stack
let copy = big;                      // memcpys all 8 KB (Copy type — a real copy)

A String moves in 24 bytes because its bulk sits behind a pointer; a [u64; 1024] or a struct of arrays moves its entire payload. So the precise rule is: a move costs the size of the value, and indirection is what makes large values small. This also decides API shapes — passing a 1 MB struct by value copies 1 MB into the callee's frame (moves in, moves out), while passing &big copies one pointer.

Stack overflow is the other limit, and you can produce it on demand:

fn deep(n: u64) -> u64 { deep(n + 1) }   // debug: 'thread main has overflowed its stack'

The default is about 8 MB on Linux and 1 MB on Windows — roughly 80k–800k frames of a typical function — and threads start smaller than the main thread. When you need more, you ask for it explicitly rather than hoping:

std::thread::Builder::new()
    .stack_size(64 * 1024 * 1024)          // 64 MB
    .spawn(worker).unwrap();

Recursive algorithms that are inherently deep (tree walks over user data) sidestep the question by carrying their own stack — a Vec of work items on the heap — trading a compiler-managed frame for a data structure whose size you control and whose growth does not depend on the thread's limits.

String: Three Stack Words Pointing at a Heap Buffer

String is the clearest window into Rust's memory model because its value is small and stack-resident while its data lives on the heap:

String value (24 bytes, on the stack)          heap buffer
┌──────────────────────┐                 ┌─────────────────────┐
│ ptr  ──────────────────────────────────▶│ 'h' 'e' 'l' 'l' 'o' │
│ len  = 5             │                 │ 1 byte per char here │
│ cap  = 5             │                 └─────────────────────┘
└──────────────────────┘

Three fields — pointer, length, capacity — describe an owned, growable UTF-8 buffer. This explains everything you have already observed: moving a String is constant-time because only those 24 bytes of stack metadata change hands while the heap buffer stays put; dropping a String runs a destructor that frees the heap buffer exactly once, through the one owner; and len() counts bytes, since that is what the buffer physically holds.

Growth is the heap's reason to exist. Building from input can start at a runtime-determined size with no compile-time knowledge:

let mut s = String::new();                    // no allocation yet (cap = 0)
s.push_str("hello");                          // allocates 5 bytes on the heap
s.push_str(", world");                       // needs 12: allocates bigger, copies, frees old
println!("{} {} {}", s.len(), s.capacity(), s);   // 12 12 hello, world

That allocate-copy-free cycle on growth is why builders call String::with_capacity(n) (or Vec::with_capacity) when the final size is estimable — one allocation instead of several reallocations. Capacity, length, and the allocation policy are the textbook reasons some types cannot be Copy: duplicates would each own the same buffer and each try to free it.

The other half of the picture is the type with no allocation: &str, a string slice. A string literal like "hello" is compiled into the binary — its bytes live in a read-only data segment for the whole program run, with type &'static str:

let literal: &'static str = "hello";   // borrows from the binary itself
let owned = String::from("hello");     // heap copy, owned, droppable
let borrowed: &str = &owned;           // borrows the heap buffer through String's deref

&'static str promises the data outlives the program — the compiler can therefore hand it out freely, and no drop ever frees it. &str without 'static borrows some buffer somewhere else; keeping it alive is the borrow checker's job. Recognizing which of the three ('static literal, owned String, borrowed &str) a piece of text is tells you its allocation, its ownership, and its drop behavior at a glance — that classification is half of real-world Rust API design.

Vec<T> is the same design with elements instead of bytes — a stack triple (ptr, cap, len) over a heap array — and its growth policy is where the numbers show up. Pushing past capacity allocates a buffer roughly double the old size and copies everything:

Operation Cost
v.push(x) with spare capacity O(1), no allocation
v.push(x) that exceeds capacity O(n): allocate ~2n, copy n, free old
n pushes from empty amortized O(1) each — total O(n) copying

Amortized is the honest word: the doubling policy makes the rare expensive push pay for the cheap ones between them. Vec::with_capacity(n) removes even that, which matters when n is large — building a 10-million-element vector without it performs about 24 reallocs (2, 4, 8, …) and copies ~20 million elements total.

Underneath sits the global allocator. Rust uses the system allocator by default — malloc/free on Unix, HeapAlloc on Windows — with no runtime of its own, and std::mem::size_of_val(&v) plus v.capacity() let you inspect the two sizes you care about. When a workload is allocation-bound, the standard library lets you swap it:

#[global_allocator]
static A: mimalloc::MiMalloc = mimalloc::MiMalloc;   // or jemalloc, or a bump arena

Two String facts that follow from capacity semantics: s.truncate(0) and s.clear() free nothing (the buffer stays allocated for reuse), while s.shrink_to_fit() reallocates down to the length — a deliberate trade of copying for memory. And because capacity never shrinks implicitly, a String that grew to 100 MB and was truncated holds 100 MB until you ask otherwise. Capacity is a promise the allocator already paid for; length is what you are currently using.

Box: One Heap Allocation, and Why Recursion Needs It

Box<T> is the simplest owning pointer: it puts a T on the heap and owns it, freeing the allocation when the Box drops. No reference counting, no garbage collection, no surprises — one allocation, one owner, deterministic free:

fn main() {
    let boxed = Box::new(5_i32);   // the i32 lives on the heap
    println!("{}", boxed);         // auto-derefs for printing: 5
}                                   // boxed drops → heap freed exactly once

In daily code the place you must reach for Box is recursive data. Consider a linked list:

enum List {
    Cons(i32, List),   // error[E0072]: recursive type has infinite size
    Nil,
}

The compiler rejects this because a List would need to contain a List containing a List… forever — its size is unbounded, and stack values must be sized. Box breaks the recursion by putting the tail behind a pointer of known size (one machine word):

enum List {
    Cons(i32, Box<List>),   // finite: 4 bytes + one pointer-sized Box
    Nil,
}

use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));

Same shape as the standard library's own Vec/String design: a small stack value pointing at heap data — except a Box is the minimal version with no capacity tricks. E0072 is the error to recognize here: "recursive type has infinite size" always means "add indirection," and Box<T> is the first tool, with Rc<T> the shared-ownership variant later.

Box also appears in three patterns worth naming. Trait objects: Box<dyn Fn(i32) -> i32> or Box<dyn Reader> puts a value of unknown concrete type behind one pointer, enabling dynamic dispatch and heterogeneous collections (the size-of-trait-object problem is exactly what the Box solves). Large stack values: a 100 KB buffer as a local can blow a small thread stack; Box::new([0u8; 100_000]) moves it to the heap. Moving data between threads: a Box<T> where T: Send can cross thread boundaries with a single ownership transfer. The common thread is indirection with ownership: Box is how you say "this value lives elsewhere, and I alone am responsible for it."

Stack, heap, and Box together complete the allocation picture that ownership governs: the compiler knows stack lifetimes statically, so it hands you automatic freeing there; heap buffers ride along behind 24-byte descriptors or one-word pointers, freed by the single owner when its scope ends.

Box's pointer is worth sizing precisely, because Box<T> and Box<dyn Trait> are not the same width:

assert_eq!(std::mem::size_of::<Box<i32>>(), 8);              // thin: data pointer only
assert_eq!(std::mem::size_of::<Box<dyn Fn()>>(), 16);        // fat: data + vtable pointer

The trait object carries where the value is and how to call it — two words — which is the entire cost of dynamic dispatch measured in bytes. Same for Box<dyn Error> in every Result you have ever returned: one enum-sized payload becoming a 16-byte indirection so the error type need not be named.

Box also defines the handoff point between Rust's ownership and the rest of the world:

let raw: *mut Config = Box::into_raw(cfg);     // ownership LEAVES Rust; no drop runs
unsafe { ffi_apply(raw); }                     // C side uses it
let back = unsafe { Box::from_raw(raw) };      // ownership RETURNS; drop frees it

into_raw transfers the single owner out without freeing, from_raw takes it back — a manual move whose discipline is checked by pairing them across the FFI boundary. The alternative to pairing them is Box::leak, which makes the allocation 'static and permanent by design.

Finally, allocation failure is defined behavior worth knowing: Rust does not return null from Box::new — the default allocation-error handler aborts the process, because unwinding from inside an allocation would run destructors against a half-built heap. The practical consequence: Box gives you one owner and deterministic free, but OOM is a process-death event, not an error you can handle in place — sizing, backpressure, and streaming are how you keep a service out of that situation. Together with the stack's fixed frames and String's descriptor, indirection with ownership is the pattern that makes all of Rust's memory model usable at runtime.

Quiz

1. Why is moving a String constant-time regardless of its length?

Question 1 options

2. What is the type and storage of the value in `let s = "hello";`?

Question 2 options

3. Why does `enum List { Cons(i32, List), Nil }` fail with E0072 'recursive type has infinite size'?

Question 3 options

4. Where do these live: a struct's i32 field, a String's bytes, and a Box<T>'s pointee?

Question 4 options

Flashcards

Question

Stack vs heap in Rust: what belongs where, and what frees each?

Answer

Stack: fixed-size, compile-time-known values (i32, structs of scalars, String/Vec descriptors); freed automatically when the frame/scope pops — no allocator call. Heap: runtime-sized, growable data (String bytes, Vec elements, Box pointees); freed when the single owning value drops.

Question

Draw the memory layout of a String.

Answer

Three stack words: (1) ptr → the heap UTF-8 buffer, (2) len — bytes currently used, (3) cap — bytes allocated. 24 bytes total on 64-bit. Moving copies only this triple; dropping frees the heap buffer once; len() counts bytes not chars; growth reallocs (bigger buffer, copy, free old).

Question

String vs &str vs &'static str — allocation, ownership, drop.

Answer

String: owned heap buffer, droppable. &str: borrowed slice into UTF-8 data elsewhere — no allocation, no drop; validity enforced by the borrow checker. &'static str: borrows data compiled into the binary (read-only segment) that lives for the whole program — never dropped, always valid.

Question

What does Box<T> give you, and when is it REQUIRED?

Answer

A single heap allocation owned by the Box value, freed deterministically on drop — the simplest owning pointer. Required for: recursive types (E0072 — the Box gives a known-size indirection), trait objects (Box<dyn Trait> — one pointer for an unsized type), and occasionally moving large values off a small stack.

Question

Why is String::with_capacity(n) sometimes much better than repeated push_str?

Answer

Each growth that exceeds capacity allocates a larger buffer, copies all bytes, and frees the old one — repeated growth is O(n²) total copying. with_capacity(n) does one allocation up front, making concatenation of n bytes O(n). Applies equally to Vec::with_capacity for pre-known sizes.

Revision Notes

Key Takeaways

  • 1. Stack values are compile-time-sized and freed by scope exit; heap values are runtime-sized and freed by their single owner's drop — ownership unifies both
  • 2. A String's value is the 24-byte (ptr, len, cap) stack triple — which is why moves are O(1), drops free the buffer once, and len() reports bytes
  • 3. String literals are &'static str compiled into the binary: no allocation, no drop, unconditionally valid
  • 4. E0072 'recursive type has infinite size' always means 'add indirection' — Box<T> is the standard fix and the shape behind Vec and String themselves
  • 5. Growth reallocs (allocate-copy-free), so with_capacity converts O(n²) building into O(n) when the final size is estimable

Interview Tips

  • • "Where does a String's data live?" — heap, behind a 24-byte stack descriptor; then immediately connect it to why moves are constant-time and drops are deterministic. That chain (layout → move cost → drop) is the answer loop interviewers listen for
  • • "Stack or heap for a struct with a String field?" — the struct itself is inline (stack if local), its String field is a descriptor inline, and the bytes are on the heap; being precise about the three levels signals real understanding
  • • "Why do recursive data structures need Box?" — value semantics give each value its full payload, so a self-containing type has infinite size (E0072); Box adds one pointer-sized indirection with known size, and its drop frees the node deterministically
  • • "&'static str — what does 'static mean?" — the reference lives as long as the program (data is in the binary), NOT that it is a global variable; a string literal is the canonical case, and owned Strings passed across threads can also produce 'static data

Cheat Sheet

Stack and Heap

Stack: fixed-size-at-compile-time values, LIFO frames, freed on scope/frame exit with no allocator call. Default for scalars, tuples, structs of scalars, and the descriptors of String/Vec/Box. Small per thread (≈1 MB Windows / 8 MB Linux) — deep recursion overflows it.

Heap: runtime-sized/growable data. Owned via a stack-resident descriptor; freed when the owner drops.

String layout:

[ptr | len | cap] (24 B, stack) ──▶ heap buffer (UTF-8 bytes)
  • move = copy the triple (O(1)) · drop = free buffer once · len() = bytes
  • growth reallocs → use with_capacity(n) for known sizes

Literal vs owned vs borrowed:

let a: &'static str = "hi";  // in the binary's read-only data; never dropped
let b = String::from("hi"); // heap; owned; droppable
let c: &str = &b;           // borrows the heap via Deref; no ownership

Box: one owning heap cell. Required for recursive types (E0072: enum L { Cons(i32, Box<L>), Nil }) and trait objects (Box<dyn Fn()>). Drop frees the cell deterministically.

Alignment: struct field order affects padding — size_of::<A{u8,u32}>() == 8.

Continue Learning

Before You Start

Next Topics

Related Reading