The stack is fast because it's dumb
You're building a text editor. You need to track the cursor position. That's just two numbers: row and column. You also need to store the actual document content. The user might paste a novel, or they might type a single word. You don't know how much space that text will take until the user starts typing. Rust handles these two cases differently, and understanding the difference explains why your code compiles or why the compiler screams at you.
The stack stores data with a known, fixed size at compile time. The heap stores data with a size determined at runtime or data that needs to be shared. Rust puts small, fixed values on the stack. It puts large or dynamic values on the heap. This split isn't just about size. It's about how the computer manages memory and how fast your code can access it.
How memory actually splits
The stack is a region of memory managed by a single pointer. Think of it like a stack of trays in a cafeteria. You grab the top tray, use it, and put it back. You can only interact with the top. The size of every tray is fixed. Because the size is fixed, Rust knows exactly how much space to reserve before the code runs. Accessing stack data is a single CPU instruction: calculate the offset from the stack pointer. No searching, no allocation, no overhead.
The heap is a pool of memory managed by an allocator. Think of it like a warehouse with numbered storage bins. You ask the warehouse manager for space, it finds empty room, gives you a label, and you store your data there. You can request any size. You can change the size later. You have to keep the label to find the data. Accessing heap data requires reading the label (a pointer), then jumping to the warehouse address. That extra jump costs time.
fn main() {
// i32 is always 4 bytes. Rust knows the size at compile time.
// It reserves 4 bytes on the stack and writes 42 directly there.
let count: i32 = 42;
// String can grow or shrink. Rust doesn't know the size yet.
// The stack holds a small control struct: pointer, length, capacity.
// The heap holds the actual bytes of the text.
let message = String::from("Hello, Rust!");
}
The stack pointer moves. That's it. No allocation, no search, no overhead.
What happens when the code runs
When a function starts, Rust moves the stack pointer down to make room for local variables. Writing count = 42 is a direct write to that stack slot. No system calls, no allocator logic. The CPU does it in a few nanoseconds.
For message, Rust calls the allocator. The allocator searches the heap for free space, marks it as used, and returns an address. Rust writes that address, the length (13), and the capacity (likely 13 or more) onto the stack. The stack variable message is actually a smart pointer. It's a struct with three fields: a pointer to the heap, the current length, and the allocated capacity. The heap holds the characters.
Accessing message[0] requires reading the pointer from the stack, then dereferencing it to jump to the heap. That indirection costs a few cycles. If the heap data is scattered, the CPU cache might miss, costing hundreds of cycles.
When the function ends, the stack pointer moves back up. count vanishes instantly. message's control struct vanishes, but the heap memory remains. Rust calls the destructor for String, which calls the deallocator to free the heap block. The heap memory is returned to the pool for reuse.
Drop order matters. Stack variables drop in reverse order of declaration. Heap data drops when the last owner releases it. This deterministic cleanup is why Rust doesn't need a garbage collector.
Real code: mixing stack and heap
Most Rust code mixes stack and heap. Structs often contain both fixed fields and dynamic data. References live on the stack but point to data elsewhere. Understanding where data lives helps you write efficient code and avoid lifetime errors.
/// Represents a user profile.
/// `id` is a fixed-size integer. `name` is dynamic text.
struct User {
id: u64,
name: String,
}
fn create_user(id: u64, name: &str) -> User {
// The struct itself lives on the stack of the caller when returned.
// Inside the struct, `id` takes 8 bytes directly on the stack.
// `name` triggers a heap allocation for the string data.
// The struct holds a pointer to that heap data.
User {
id,
name: name.to_string(),
}
}
Notice name: &str in the argument. That's a reference. It lives on the stack and points to data owned by the caller. &str is a "fat pointer": it contains a pointer to the data and the length. It takes 16 bytes on the stack. name.to_string() copies that data onto the heap so the User owns it. This is the owning versus borrowing pattern. The stack holds the pointers; the heap holds the owned data.
Convention aside: use &str for function arguments whenever possible. It accepts both &String and &str literals. It avoids unnecessary heap allocations. Convert to String only when you need to own the data.
String grows by doubling capacity. When you push a character, if capacity is full, Rust allocates a new, larger block on the heap, copies data, and frees the old block. This amortizes the cost. Stack variables never reallocate. Their size is immutable.
Own the data on the heap, point to it on the stack. That's the rhythm of Rust memory.
Pitfalls and compiler errors
Putting too much data on the stack causes a stack overflow. The stack has a limited size, usually a few megabytes. If you declare a massive array, the program crashes.
fn bad_stack_usage() {
// 10 million bytes is 10 MB.
// This exceeds the default stack size on many systems.
// The program will panic with a stack overflow.
let big_array: [u8; 10_000_000] = [0; 10_000_000];
}
Use Vec for large collections. Vec puts the data on the heap. The stack only holds the pointer, length, and capacity.
Returning a reference to local data is another common error. Local data lives on the stack of the function. When the function returns, the stack frame is destroyed. The reference would point to garbage.
fn bad_function() -> &String {
let s = String::from("local");
// E0515: cannot return reference to local variable `s`
&s
}
The compiler rejects this with E0515. The variable s is dropped at the end of the function. Returning &s would leave a dangling pointer. Rust forces you to return the owned String instead, which moves the heap data to the caller. The caller gets ownership. The heap data stays alive.
If you need to return a reference, the data must live longer than the function. Use a static string, or pass the data in as a parameter and return a reference to it.
If the size is huge, the stack will reject you. Move it to the heap before the compiler saves you from a crash.
Performance: cache locality and indirection
Stack data enjoys cache locality. When you iterate over an array on the stack, the elements are contiguous. The CPU prefetches the next chunk automatically. Heap allocations can be scattered. Iterating over a linked list on the heap causes cache misses. Each node might be in a different memory page.
Rust's Vec mitigates this by keeping elements contiguous on the heap. The vector allocates a single block for all elements. Iterating over a Vec is fast, almost as fast as a stack array. The only cost is the initial pointer indirection.
Box<T> adds indirection. It moves a value to the heap. Use it when you need to share ownership, or when the value is too large for the stack. Don't use Box for small values. The indirection hurts performance.
fn process_data(data: &[u8]) -> u64 {
// Iterating over a slice is fast.
// The data is contiguous in memory.
// The CPU prefetches efficiently.
data.iter().map(|&b| b as u64).sum()
}
Slices (&[T]) are fat pointers on the stack. They point to contiguous data. The data might live on the stack, the heap, or in static memory. The slice doesn't care. It just provides a view. This abstraction is zero-cost. The compiler optimizes it away.
Stack for speed and simplicity. Heap for size and sharing. The borrow checker enforces the boundary.
Decision: stack or heap
Use the stack for values with a known, fixed size at compile time. Integers, floats, booleans, and small structs fit here. Access is fast because the CPU calculates the address directly. No allocation overhead. No pointer indirection.
Use the heap for data whose size is determined at runtime. Strings, vectors, and dynamic collections belong here. The heap allows growth and sharing, at the cost of allocation overhead and pointer indirection.
Use Box<T> when you need to move a large value to the heap without changing its type. This helps with recursive types or reducing stack pressure. The box holds a pointer on the stack and the value on the heap.
Use Vec<T> when you need a growable array. The vector stores a pointer, length, and capacity on the stack, while the elements live on the heap. Elements are contiguous, preserving cache locality.
Use Rc<T> or Arc<T> when multiple owners need to share heap data. Reference counting keeps the data alive as long as any owner exists. Rc is single-threaded. Arc is thread-safe.
Reach for &str and &[T] when you only need to read data. Slices avoid ownership and allocation. They work with stack data, heap data, and static data.
Stack for speed and simplicity. Heap for size and sharing. The borrow checker enforces the boundary.