Skip to content

Stack and heap

Level: 101 → 201 · working knowledge

One line: Rust has no keyword that puts a value on the heap. let always makes a stack slot, and whether that slot is the data or a pointer to the data is a property of the type — which is what prices every move, copy and clone you will ever write.

let n = 42;                      // 4 bytes, and all of them on the stack
let s = String::from("hello");   // 24 bytes on the stack, 5 on the heap
let b = Box::new(42);            // 8 bytes on the stack, 4 on the heap

Three declarations, one keyword, three different memory layouts. Nothing in the syntax chose; the types did.

Two regions, and only one of them can grow

Stack Heap
Allocating move a pointer ask an allocator for a free region
Freeing move it back hand the region back for reuse
Size known at compile time at run time
Bounded by a few MB, per thread, fixed at spawn available memory
Sharing each thread has its own, so nothing to synchronise shared, so crossing threads needs Arc
Cost a bump of the stack pointer bookkeeping, and a possible trip to the OS

The stack behaves like a stack of plates: values are pushed as a function is entered and popped when it returns, in strict reverse order. That order is what makes freeing free — there is no list of live regions to consult, only a pointer to move back.

The heap has no such order. A value put there may outlive the function that created it, which is exactly why it is available, and exactly why it costs more: the allocator has to track which regions are free and find one that fits.

The bounded region is the stack, and that catches people out. A few megabytes sounds ample until a single array asks for it, and there is no growing out of it — the thread's stack size is fixed when it is spawned, so overflowing is an abort rather than a slowdown.

The type decides, not a keyword

This is the part that transfers badly from C, and it is the whole idea of the page. In C you choose per allocation: int x; is on the stack and malloc(sizeof(int)) is on the heap, and the same type can be either. In Rust the choice is baked into the type before you write the line. A [i32; 5] never heap-allocates and a String always does, and neither decision is available at the use site.

  • Fixed size, known at compile time → the stack slot is the value. Scalars (i32, bool, char), tuples, arrays, and structs of those.
  • Growable, or sized only at run time → a fixed-size header on the stack, data on the heap. String, Vec<T>, HashMap<K, V>, Box<T>.

Box<T> is the one place you do say it: Box::new(v) takes a value and puts it on the heap, leaving one pointer behind. It is Rust's nearest thing to malloc, and it exists for the two cases the rules above cannot express — a value too big for the bounded region, and a type whose size depends on itself.

What size_of can see, and what it cannot

Verified output of stack_and_heap.rs — regenerated by tools/run_examples.py, never hand-typed.

1. Fixed size, known at compile time -> the stack slot IS the value
   i32              4
   bool             1
   char             4   a Unicode scalar, not a byte
   (f64, f64)      16   a tuple is its fields
   [i32; 5]        20   an array is length x element
   Point            8   a struct is its fields, and nothing else
   Making one costs a bump of the stack pointer: p = (1, 2)

2. Growable or unknown size -> a fixed HEADER on the stack, data on the heap.
   The header is all `size_of` can see, so it does not move with the content:
   String holding    5 bytes of text -> 24 bytes on the stack
   String holding 5000 bytes of text -> 24 bytes on the stack
   Vec of 3 Strings                  -> 24 bytes on the stack
   ptr + len + capacity, three words, whatever is at the other end.
   `size_of_val` reports what a value OCCUPIES, never what it ALLOCATED:
     size_of_val(&roomy)           =  24   the header again
     size_of_val(roomy.as_str())   =   3   the live text, on the heap
     roomy.capacity()              =  64   what was actually bought
   Hand it the str and it does see heap bytes -- but it answers len, not capacity.

3. Every pointer type costs the same on the stack. What differs is the far end:
   Box<String>      8   one owner, freed when it drops
   Rc<String>       8   several owners, counted, one thread
   Arc<String>      8   several owners, counted atomically, many threads
   &String          8   borrowed, owns nothing
   &str            16   ptr + len: a view needs no capacity

4. Box is the one place you SAY 'put this on the heap'
   [u8; 4096]         4096 bytes on the stack
   Box<[u8; 4096]>       8 bytes on the stack, 4096 on the heap
   The far number is COMPUTED, not claimed: `&boxed` is the pointer and
   `&*boxed` is what it points at, so deref first and size_of_val crosses over.
   The stack is a few MB per thread and cannot grow. That is the whole reason
   Box exists: the array is the same size either way, but only one of the two
   spends the bounded region on it.

Three things in that output are worth keeping.

A String holding 5 bytes and a String holding 5,000 are the same 24 bytes on the stack. Those 24 are a pointer, a length and a capacity — the anatomy of a String draws the picture. The text is at the other end of the pointer, and it is not part of the type's size.

size_of_val reports what a value occupies, never what it allocated. Given the String it answers 24 — the header again. Hand it the str instead and it does reach the heap, but it answers the live length and not the buffer: a String with capacity(64) holding three characters reports 3, and the other 61 bytes are bought, untouched and invisible. Nothing in std totals what a value is really holding — that is what an allocator you can count through is for.

Every owning pointer costs one word. Box<T>, Rc<T> and Arc<T> are 8 bytes each on the stack. What differs between them is entirely at the far end — one owner, several counted owners, or several owners counted atomically. A &str is the exception at 16, because a view needs a length as well as a pointer and cannot borrow the owner's.

What each duplication does to the heap side

This is the table the split pays for. Every row is the same question — does this touch the heap? — and the answers are what make one of these free and another one a latency spike.

Operation Stack side Heap side Proved on
Move (let b = a; on a non-Copy type) the header is copied into a new slot, the old name goes dead untouched — not copied, not freed What an address shows
Copy (let b = a; on an i32, a Point) the bits are duplicated, both names stay alive never involved: a Copy type has no heap side Copy vs Clone
Clone (a.clone()) a new header a second buffer, and the bytes copied into it Copy vs Clone
Rc::clone / Arc::clone a new 8-byte pointer untouched — one counter incremented Rc, Arc

Two readings of that table are worth making explicit, because they are the reason it is on this page rather than four separate ones.

A move is cheap for a reason you can now state. Moving a String copies 24 bytes between stack slots and leaves the text alone, however long it is. That is why Rust can afford to move by default, and why "moves are expensive for big values" — true in C++ before std::move — is not true here.

.clone() is the only row that allocates, which is what makes it the one worth flinching at in a hot path. The flinch should be calibrated rather than reflexive: Rc::clone and Arc::clone are spelled .clone() too and allocate nothing, so the word in the source does not tell you the cost — the type at the other end does. Where a clone is genuinely needed in a loop, clone_from can refill a buffer you already own instead of buying a new one.

Why any of this is visible from outside

A profile shows heap traffic and cannot show stack traffic. Allocation and deallocation go through one global allocator you can instrument; a stack push is a register-sized arithmetic instruction with nothing to hook. So a function that only touches the stack is invisible to a memory profiler no matter how hot it is, and a .clone() in a request path is a line in the allocator's counters.

That asymmetry is why the table above is worth carrying: the operations that cost nothing to observe are also the ones that cost nothing to run, and the single row that allocates is the single row you can see.

If you are coming from another language

Python. You have never made this choice, and the reason is that CPython has already made it: every object is on the heap, including the integer 42, and a name is a reference to it. sys.getsizeof(x) is the closest thing to size_of_val and it behaves the opposite way — it reports the object's own heap footprint and excludes what it refers to, so sys.getsizeof([1,2,3]) counts the list and not the integers. What transfers is the shape of String: a Python list is also a small header pointing at a separately allocated buffer that doubles as it grows, which is why append is amortised O(1) there for the same reason push is here. What changes is that Rust lets you decline the indirection entirely — [i32; 5] has no header and no allocation, and there is no Python equivalent to reach for. And a reference-counted heap value is Rc here rather than the default, so you pay the count only where you asked for it.

ABAP. The split you already have is DATA versus CREATE DATA. An elementary field or a structure declared with DATA lives in the program's own storage and is freed when the procedure ends — the stack case. An object or a data reference created with CREATE OBJECT / CREATE DATA lives on the heap and is freed by the garbage collector when the last REF TO drops, which is the Box/Rc case with the counting hidden. The trap coming this way is the internal table: lt_a = lt_b copies the whole table (ABAP tables are copy-on-write under the hood, but semantically a copy), so the ABAP instinct that assignment duplicates is right for tables and wrong for Vec — in Rust that same line is a move, and the buffer is not copied at all. Vec is closer to a data reference you cannot alias than to an internal table.

C. The mechanism is identical and the decision point moves. int x; versus malloc is a choice you make per allocation; [i32; 5] versus Vec<i32> is a choice you make once, in the type, and every use site inherits it. The three things that change: there is no free, because the owner's scope end is the free and the compiler knows where that is; returning a pointer to a local is a compile error rather than the classic dangling-pointer bug; and sizeof on a String-equivalent gives you the header here too, so the C intuition that sizeof never sees the far end of a pointer transfers exactly.

C++. String is std::string (minus the small-string optimisation — Rust's String always heaps its bytes, so short strings do not get the free ride they get in libstdc++), Vec<T> is std::vector<T>, Box<T> is std::unique_ptr<T>, Rc/Arc are std::shared_ptr. The one that matters is the default: MyType b = a; copies in C++ and moves in Rust, so the expensive operation is the one you have to ask for here and the one you have to suppress there.

Practice

Predict seven sizes, then predict which of seven lines allocates. Both halves are guesses you write down before you run anything — the point is to find out which of your intuitions about the split is wrong, and a number you read off the screen teaches nothing.

  1. Write down the stack size of i32, [i32; 5], a two-u32 struct, &str, Box<i32>, String and Vec<i32>. Then print all seven with size_of. The pair worth staring at is [i32; 5] against Vec<i32>: say which one's number would change if it held a million elements, and which one would be four megabytes of stack.
  2. Build a String::with_capacity(64) holding three bytes. Predict size_of_val on the String, size_of_val on the str behind it, and capacity(). Three different numbers come out; say which of them is the amount of heap the value is actually holding, and why nothing in std will tell you.
  3. Predict which of these allocate, then count them: a move, a Copy, taking a &str slice, a .clone(), a Box::new, an Rc::new, and an Rc::clone. Counting means a #[global_allocator] that increments an AtomicUsize — an allocation is not visible any other way, which is the page's last section made into an exercise. Keep println! out of the measured region: formatting allocates.

Then confirm the two claims the table makes rather than trusting them: that the move left the buffer where it was, and that the .clone() did not. Compare the two as_ptr() values, never a printed address — that differs on every run and would poison the recorded key.

Solution

stack_and_heap_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: predict six sizes, then predict which of seven lines allocates.
//!
//!   rustc --edition 2024 stack_and_heap_kata.rs -o /tmp/sahk && /tmp/sahk
//!
//! Nothing here prints an address. A raw pointer differs every run and would
//! poison the recorded key, so every claim about "the same buffer" is a
//! comparison, printed as the boolean it produces.

use std::alloc::{GlobalAlloc, Layout, System};
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

static ALLOCS: AtomicUsize = AtomicUsize::new(0);

struct Counting;

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        ALLOCS.fetch_add(1, Relaxed);
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

/// Run `work` and report only what it cost. Nothing is printed inside the
/// measured region: `println!` allocates.
fn measure<T>(label: &str, work: impl FnOnce() -> T) -> T {
    let before = ALLOCS.load(Relaxed);
    let out = work();
    let after = ALLOCS.load(Relaxed);
    println!("    {label:<32} alloc {}", after - before);
    out
}

#[derive(Clone, Copy, Debug)]
struct Point {
    x: u32,
    y: u32,
}

fn main() {
    println!("Part 1 — predict the stack size of six types.\n");
    println!("    {:<14} {:>4}  {}", "type", "size", "why");
    println!("    {:<14} {:>4}  {}", "i32", size_of::<i32>(), "the slot IS the value");
    println!("    {:<14} {:>4}  {}", "[i32; 5]", size_of::<[i32; 5]>(), "five of them, still no header");
    println!("    {:<14} {:>4}  {}", "Point", size_of::<Point>(), "two u32 fields, nothing more");
    println!("    {:<14} {:>4}  {}", "&str", size_of::<&str>(), "pointer + length: a view needs both");
    println!("    {:<14} {:>4}  {}", "Box<i32>", size_of::<Box<i32>>(), "one pointer, the i32 is elsewhere");
    println!("    {:<14} {:>4}  {}", "String", size_of::<String>(), "pointer + length + capacity");
    println!("    {:<14} {:>4}  {}", "Vec<i32>", size_of::<Vec<i32>>(), "same three words, any length");

    println!("\n  The pair to compare is `[i32; 5]` at 20 and `Vec<i32>` at 24.");
    println!("  The array's 20 bytes ARE its five integers. The Vec's 24 are a");
    println!("  header and none of its data, so a Vec of a million elements is");
    println!("  still 24 and a `[i32; 1_000_000]` would be four megabytes of stack.");

    println!("\nPart 2 — what size_of_val can and cannot see.\n");
    let mut roomy = String::with_capacity(64);
    roomy.push_str("abc");
    println!("    size_of_val(&roomy)   {}   the header, again", size_of_val(&roomy));
    println!("    size_of_val(&*roomy)  {}    the str behind it: live length", size_of_val(&*roomy));
    println!("    roomy.capacity()      {}   bought, and invisible to both", roomy.capacity());
    println!("\n  Neither number is what the value is holding. 61 bytes are reserved,");
    println!("  untouched, and reported by nothing in std. Counting them takes an");
    println!("  allocator you can instrument, which is what part 3 uses.");

    println!("\nPart 3 — predict which of seven lines allocates, then count.\n");

    let source = String::from("a heap-allocated string");
    let before_move = source.as_ptr();
    let moved = measure("let moved = source;", || source);
    println!("      ...and the buffer did not move: {}", before_move == moved.as_ptr());

    let point = Point { x: 7, y: 431 };
    let copied = measure("let copied = point;", || point);
    println!("      ...a Copy type has no heap side to touch, and both names");
    println!("         are still alive: point {} / copied {}",
             point.x, copied.y);

    let _view: &str = measure("let view = &moved[..];", || &moved[..]);
    println!("      ...a borrow is a pointer and a length, both on the stack");

    let cloned = measure("moved.clone()", || moved.clone());
    println!("      ...the one row that buys a second buffer: {}",
             moved.as_ptr() != cloned.as_ptr());

    let boxed = measure("Box::new(41u32)", || Box::new(41u32));

    let shared = measure("Rc::new(cloned)", || Rc::new(cloned));
    let _second = measure("Rc::clone(&shared)", || Rc::clone(&shared));
    println!("      ...one owner more, one counter up, no bytes copied: {}",
             Rc::strong_count(&shared));

    println!("\n  Four of the seven allocate nothing. The three that do are the");
    println!("  three that put something NEW on the heap -- a clone's buffer, a");
    println!("  Box's payload, an Rc's counted allocation. Everything else moves");
    println!("  or copies a header between stack slots, which is why Rust can");
    println!("  afford to move by default however long the text is.");
    println!("\n  boxed still says {boxed}, and the rule to carry away is that");
    println!("  `.clone()` names the only one you have to look at the TYPE to");
    println!("  price: on a String it allocates, on an Rc it does not.");
}

Verified output of stack_and_heap_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

Part 1 — predict the stack size of six types.

    type           size  why
    i32               4  the slot IS the value
    [i32; 5]         20  five of them, still no header
    Point             8  two u32 fields, nothing more
    &str             16  pointer + length: a view needs both
    Box<i32>          8  one pointer, the i32 is elsewhere
    String           24  pointer + length + capacity
    Vec<i32>         24  same three words, any length

  The pair to compare is `[i32; 5]` at 20 and `Vec<i32>` at 24.
  The array's 20 bytes ARE its five integers. The Vec's 24 are a
  header and none of its data, so a Vec of a million elements is
  still 24 and a `[i32; 1_000_000]` would be four megabytes of stack.

Part 2 — what size_of_val can and cannot see.

    size_of_val(&roomy)   24   the header, again
    size_of_val(&*roomy)  3    the str behind it: live length
    roomy.capacity()      64   bought, and invisible to both

  Neither number is what the value is holding. 61 bytes are reserved,
  untouched, and reported by nothing in std. Counting them takes an
  allocator you can instrument, which is what part 3 uses.

Part 3 — predict which of seven lines allocates, then count.

    let moved = source;              alloc 0
      ...and the buffer did not move: true
    let copied = point;              alloc 0
      ...a Copy type has no heap side to touch, and both names
         are still alive: point 7 / copied 431
    let view = &moved[..];           alloc 0
      ...a borrow is a pointer and a length, both on the stack
    moved.clone()                    alloc 1
      ...the one row that buys a second buffer: true
    Box::new(41u32)                  alloc 1
    Rc::new(cloned)                  alloc 1
    Rc::clone(&shared)               alloc 0
      ...one owner more, one counter up, no bytes copied: 2

  Four of the seven allocate nothing. The three that do are the
  three that put something NEW on the heap -- a clone's buffer, a
  Box's payload, an Rc's counted allocation. Everything else moves
  or copies a header between stack slots, which is why Rust can
  afford to move by default however long the text is.

  boxed still says 41, and the rule to carry away is that
  `.clone()` names the only one you have to look at the TYPE to
  price: on a String it allocates, on an Rc it does not.

See also

Two neighbouring topics a reader arriving from a memory-model chapter will look for, and where they live: reference cycles and Weak are on the Rc page, and Send and Sync are in marker traits.

Po polsku

Stos (stack) i sterta (heap) to podział znany z C, ale Rust wprowadza jedną różnicę, która zaskakuje: nie ma słowa kluczowego, które umieszcza wartość na stercie. Nie ma new. let zawsze tworzy miejsce na stosie, a to, czy w tym miejscu leżą dane, czy wskaźnik do danych, jest cechą typu.

String i Vec<T> to trzy słowa maszynowe na stosie — wskaźnik, długość, pojemność — plus bufor na stercie. [u8; 1024] to kilobajt leżący w całości na stosie. Box<T> to jedno słowo na stosie i wartość na stercie. Tego nie widać w składni let, tylko w typie.

To jest właśnie mechanizm, który wycenia każde przeniesienie, kopiowanie i klonowanie, jakie kiedykolwiek napiszesz. Przeniesienie String kopiuje trzy słowa i nie dotyka sterty. clone() kopiuje bufor. Ta sama składnia, dwa zupełnie różne rachunki.

size_of::<T>() pokazuje tylko część na stosie i nic nie mówi o stercie — size_of::<String>() to 24 na 64-bitowej maszynie niezależnie od tego, czy łańcuch jest pusty, czy ma megabajt.

Uwaga na tłumaczenie: polskie „sterta” bywa mylone ze strukturą danych kopiec (też „heap” po angielsku, np. w kopcu binarnym). To dwa różne pojęcia o tej samej angielskiej nazwie.

Szukaj po polsku: stos i sterta · alokacja pamięci w Ruscie · Box Rust · rust stack vs heap · size_of