Back to lessons
Scrollytelling

Stack vs Heap

How Rust manages memory allocation

Two Regions of Memory

When a program runs, the operating system gives it memory organized into two main regions: the stack and the heap. Understanding how these work is fundamental to writing efficient Rust code -- and to understanding why Rust makes the design choices it does.

  Process Memory Layout
  ─────────────────────────
  High addresses
  ┌─────────────────────┐
  │       Stack          │ ↓ grows downward
  │                      │
  │  (function frames,   │
  │   local variables)   │
  ├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
  │                      │
  │     free space       │
  │                      │
  ├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
  │       Heap           │ ↑ grows upward
  │                      │
  │  (dynamic allocs,    │
  │   Box, String, Vec)  │
  ├──────────────────────┤
  │   Static/Global      │
  ├──────────────────────┤
  │   Code (text)        │
  └──────────────────────┘
  Low addresses

The stack and heap serve different purposes and have very different performance characteristics. Rust gives you explicit control over which region is used, and the ownership system ensures that both are managed safely.

The Stack

The stack is a Last-In, First-Out (LIFO) data structure managed automatically by the CPU. When a function is called, a new stack frame is pushed onto the stack. When the function returns, its frame is popped. This makes allocation and deallocation extremely fast -- just moving a pointer.

  Stack Frames During Execution
  ──────────────────────────────

  fn main() {              fn add(a: i32, b: i32) -> i32 {
      let x = 5;              let result = a + b;
      let y = 10;             result
      let sum = add(x, y);  }
  }

  Step 1: main() called     Step 2: add() called
  ┌───────────────────┐     ┌───────────────────┐
  │ main              │     │ add               │ ← top
  │   x = 5           │     │   a = 5           │
  │   y = 10          │     │   b = 10          │
  │   sum = ?         │     │   result = 15     │
  └───────────────────┘     ├───────────────────┤
  ↑ stack pointer            │ main              │
                             │   x = 5           │
  Step 3: add() returns      │   y = 10          │
  ┌───────────────────┐     │   sum = ?         │
  │ main              │     └───────────────────┘
  │   x = 5           │     ↑ stack pointer
  │   y = 10          │
  │   sum = 15        │
  └───────────────────┘
  ↑ stack pointer

Stack properties

  • Very fast: allocation is just incrementing the stack pointer (a single CPU instruction).
  • Fixed size: the compiler must know the exact size of every value at compile time.
  • Automatic cleanup: when a function returns, its entire frame is deallocated instantly.
  • Limited space: typically 1-8 MB per thread (stack overflow if exceeded).

The Heap

The heap is a large pool of memory used for dynamic allocation -- when you don't know the size of your data at compile time, or when data needs to outlive the function that created it. Unlike the stack, the heap requires explicit management: you must request memory from the allocator and eventually return it.

  Heap Allocation
  ────────────────────────────────

  let s = String::from("hello");

  Stack                    Heap
  ┌──────────────────┐     ┌───┬───┬───┬───┬───┐
  │ s                │     │ h │ e │ l │ l │ o │
  │   ptr ───────────│────→│   │   │   │   │   │
  │   len: 5         │     └───┴───┴───┴───┴───┘
  │   capacity: 5    │     address: 0x7f2a
  └──────────────────┘

  The String struct itself (ptr, len, capacity)
  lives on the stack (24 bytes on 64-bit).
  The actual character data lives on the heap.

Heap properties

  • Flexible size: allocations can be any size, determined at runtime.
  • Slower: the allocator must search for a free block and manage fragmentation.
  • Manual lifetime: in C, you call malloc/free. In Rust, the ownership system handles this automatically.
  • Shared across threads: heap data can be passed between threads (with proper synchronization).

In C, forgetting to free heap memory causes leaks; freeing it twice causes crashes. In garbage-collected languages, the GC handles this but adds runtime overhead. Rust takes a unique approach: the compiler tracks ownership and inserts the deallocation call at exactly the right place -- no GC, no manual free.

Rust on the Stack

In Rust, all values with a known fixed size at compile time live on the stack by default. This includes all primitive types, fixed-size arrays, tuples, and structs composed of fixed-size fields.

stack_types.rs -- Types that live on the stack
fn main() {
    // Integers: size known at compile time
    let x: i32 = 42;         // 4 bytes on the stack
    let y: u64 = 1_000_000;  // 8 bytes on the stack

    // Floating point
    let pi: f64 = 3.14159;   // 8 bytes on the stack

    // Booleans
    let active: bool = true;  // 1 byte on the stack

    // Tuples: size = sum of element sizes
    let point: (f64, f64) = (1.0, 2.0);  // 16 bytes

    // Fixed-size arrays: size = element_size * length
    let matrix: [i32; 9] = [0; 9];  // 36 bytes on stack

    // Structs with fixed-size fields
    struct Point3D {
        x: f64,  // 8 bytes
        y: f64,  // 8 bytes
        z: f64,  // 8 bytes
    }
    let origin = Point3D { x: 0.0, y: 0.0, z: 0.0 };
    // 24 bytes on the stack

    println!("x={}, pi={}, point={:?}", x, pi, point);
    println!("origin: ({}, {}, {})", origin.x, origin.y, origin.z);
}   // all stack memory freed instantly when main returns
  main's Stack Frame
  ────────────────────────────────
  ┌──────────────────────────────┐
  │  origin.z : f64    (8 bytes) │
  │  origin.y : f64    (8 bytes) │
  │  origin.x : f64    (8 bytes) │
  │  matrix   : [i32;9] (36 b)  │
  │  point    : (f64,f64) (16b) │
  │  active   : bool   (1 byte) │
  │  pi       : f64    (8 bytes) │
  │  y        : u64    (8 bytes) │
  │  x        : i32    (4 bytes) │
  └──────────────────────────────┘
  Total: ~97 bytes on the stack
  Allocated in nanoseconds. Freed in nanoseconds.

Stack-allocated types in Rust implement the Copy trait: when you assign them to another variable, the value is copied bit-for-bit instead of moved. This is cheap because the data is small and already on the stack.

Rust on the Heap

When data has a size that can change at runtime, or when you need it to outlive the current scope, Rust puts it on the heap. The three most common heap-allocated types are String, Vec<T>, and Box<T>.

heap_types.rs -- Types that use the heap
fn main() {
    // String: dynamically-sized text
    let mut name = String::from("Hello");
    name.push_str(", world!");  // heap buffer may be reallocated
    // Stack: { ptr, len: 13, capacity: ... }
    // Heap:  "Hello, world!"

    // Vec<T>: dynamically-sized array
    let mut numbers: Vec<i32> = Vec::new();
    numbers.push(1);
    numbers.push(2);
    numbers.push(3);
    // Stack: { ptr, len: 3, capacity: 4 }
    // Heap:  [1, 2, 3, _]

    // Box<T>: put a single value on the heap
    let boxed: Box<i32> = Box::new(42);
    // Stack: { ptr }  (8 bytes, just a pointer)
    // Heap:  42        (4 bytes)

    println!("name={}, numbers={:?}, boxed={}", name, numbers, boxed);
}   // Drop order: boxed, numbers, name
    // Each one frees its heap allocation automatically.
  Stack vs Heap: String, Vec, Box
  ──────────────────────────────────────────

  Stack                         Heap
  ┌─────────────────────┐
  │ name                │       ┌──────────────────┐
  │   ptr ──────────────│──────→│ H e l l o ,   w  │
  │   len: 13           │       │ o r l d !        │
  │   cap: 13           │       └──────────────────┘
  ├─────────────────────┤
  │ numbers             │       ┌────┬────┬────┬────┐
  │   ptr ──────────────│──────→│  1 │  2 │  3 │  _ │
  │   len: 3            │       └────┴────┴────┴────┘
  │   cap: 4            │       (capacity for 1 more)
  ├─────────────────────┤
  │ boxed               │       ┌────┐
  │   ptr ──────────────│──────→│ 42 │
  └─────────────────────┘       └────┘

  The stack part is always a fixed size (ptr + metadata).
  The heap part can grow or shrink at runtime.

When these values go out of scope, Rust automatically calls drop(), which frees the heap memory. No garbage collector, no manual free. The compiler inserts the deallocation at exactly the right point in the compiled code, determined by ownership rules.

Summary

Rust gives you explicit control over memory allocation while keeping you safe. You always know whether your data is on the stack or the heap, and the ownership system ensures it is freed at the right time.

Stack: fast and automatic

Fixed-size types (i32, f64, bool, tuples, arrays, structs) live on the stack. Allocation is a single CPU instruction. Cleanup is instant when the function returns.

Heap: flexible and controlled

Dynamic types (String, Vec<T>, Box<T>) put their data on the heap with a fixed-size handle on the stack. The ownership system frees heap memory automatically when the owner goes out of scope.

You choose, Rust ensures safety

Unlike garbage-collected languages where the runtime decides, and unlike C where you manage everything manually, Rust gives you the control of C with the safety of a managed language. The type system tells you exactly where your data lives.