Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

🥽Virtual Lab: Rust Memory

This virtual lab explores how Rust’s value types occupy memory. You’ll see two surfaces (the stack and the heap) and discover that some types live entirely on the stack, while others keep a fixed-size record on the stack and put their actual data over on the heap.

The focus here is purely about size and layout. None of Rust’s verbs (such as move, borrow, and drop) appear in this lab. Those come next. Before you can reason about who owns what, you need to see what shape each binding has in memory.

In Rust, every binding you declare has a known size at compile time. Primitives like i32 and bool are fully stack-resident. Owned heap-backed types like Box<T>, String, and Vec<T> are two-part: a small fixed-size record on the stack, plus a separate allocation on the heap that the stack record points at. By the end of this virtual lab you’ll be able to predict, for any binding, how many stack bytes it claims and how many heap bytes (if any) “magically appear”.

We are looking at fairly simple allocations, such as:

let a: i32 = 545; // 4 bytes on the stack, no heap
let b: Box<i32> = Box::new(42); // 8 bytes on the stack (a pointer) + 4 bytes on the heap
let s: String = String::from("hello"); // 24 bytes on the stack (ptr+len+cap) + 5 bytes on the heap

If any of the words used in this lab feel hazy, here’s a short reference. Skim it now, come back as needed.

TermMeaning
StackA region of memory where bindings with a known compile-time size live. Allocation happens automatically when a scope opens; deallocation happens automatically when the scope closes.
HeapA separate region of memory used for values whose size is decided at run time, or whose lifetime needs to outlive the scope they were created in. Allocation and deallocation are explicit (in Rust, usually managed by the owning type).
BindingThe Rust term for a let-introduced name attached to a value. Roughly equivalent to “local variable” in other languages, but with stricter rules about ownership.
AddressThe location in memory where something lives, written as a hexadecimal number like 0x7FFFFFE0. Stack and heap addresses are typically very far apart.
PointerA value whose contents are an address (a small number, 8 bytes on a 64-bit machine) that says “the real data is over there.”
Box<T>An owning pointer to a single value of type T stored on the heap. The Box itself is 8 bytes (just the pointer); the T lives on the heap.
StringA growable, owned, UTF-8 text buffer. The stack record is 24 bytes (three machine words: a pointer, a length, and a capacity), and the actual bytes of the string live on the heap.
Vec<T>A growable, owned array of T. Same 24-byte fat-pointer stack layout as String; the elements live on the heap.
Fat pointerAn informal term for a multi-word stack record that points into the heap. String and Vec are fat-pointer types: pointer + length + capacity.
Unicode scalar valueA single Unicode code point (excluding surrogates). Rust’s char stores one of these in 4 bytes (not one byte like C/C++).

Below this guide is the interactive lab itself. The interface has three regions stacked vertically:

  1. The stack strip is a 32-byte slice of the stack. Each square is one byte. The stack base address (0x7FFFFFE0) is shown in the header, and each byte to the right is one address higher.
  2. The heap strip is a separate 32-byte slice of the heap, with its own base address (0x00603000); note how very different that is from the stack address. The heap is laid out the same way visually, but bindings don’t live here directly. Heap blocks appear automatically as a side-effect of placing an owning binding (a Box, String, or Vec) on the stack.
  3. The type palette runs below both strips. Drag any chip onto the stack strip to declare a binding of that type. The chip’s badge tells you the stack cost (e.g. i32 4B) and, for owned types, that there’s also heap involved (String 24B + heap).

Below the strips, the Declared bindings table summarises every active binding: its type, name, value, stack address, and how many bytes it claims on each region.

You can:

  • Drag a type chip onto the stack to declare a new binding.
  • Click an existing binding to edit its name and value (the stack and heap layout updates live).
  • Click the × badge on any stack block to remove the binding (its heap block, if any, is removed too).
  • Worked example restores the original starting state.
  • Clear all removes every binding from both strips.

The lab opens with two bindings already in place: a Box<i32> named a at the start of the stack, and a String named b immediately after it. Together they fill the 32-byte stack region exactly. The walkthrough below builds on that starting state.


Before you tackle the exercises, take a quick walk through the lab so you know what every part of the picture means.

Look at the lab below in its initial state. The stack contains two bindings: a: Box<i32> at offset 0 and b: String at offset 8. The byte counter in the stack header reads 32 / 32 bytes used because Box<i32> (occupies 8 bytes) + String (occupies 24 bytes) = 32 bytes exactly.

Now look down at the heap strip directly below. You’ll see two blocks there too, labelled *a and *b. The asterisk is a hint that these are the targets of the stack pointers above (representing what a and b actually own). The *a block is 4 bytes (to contain the i32 value 42); the *b block is 5 bytes (to contain the UTF-8 bytes of "hello").

Notice the stack and heap have completely different simulated base addresses. The stack is up near 0x7FFFFFE0; the heap is far below at 0x00603000. In a real running program these two regions are typically gigabytes apart in the address space. The stack record on top doesn’t physically contain the heap data; rather, it contains a pointer that refers to it.

Click on b (the String) in the stack strip. The editor opens. Change the value from hello to hi, then Save.

Watch the heap strip: the *b block shrinks from 5 bytes to 2 bytes. The stack record didn’t change size; it’s still 24 bytes, because a String is always a 24-byte fat pointer no matter how long the string is. What changed is the heap allocation that the stack record points at. This is the whole point of the heap: the stack stays a fixed shape, and variable-length data lives over there.

Step 3: Empty the stack and add a primitive

Section titled “Step 3: Empty the stack and add a primitive”

Click Clear all. Both strips empty out. Now drag an i32 chip onto the stack. Name it whatever you like and give it a value.

Notice three things: the stack shows a 4-byte block, the heap remains empty, and the bindings table shows Stack: 4B and Heap: none. Primitives never touch the heap. They live entirely on the stack, with their value stored directly in the bytes.

You’re ready for the exercises.


Rust Memory Lab

Drag a Rust type onto the stack. Owned types — Box, String, Vec — also claim space on the heap.

Stackbase = 0x7FFFFFE0
32 / 32 bytes used0 free
+0
+8
+10
+18
a
0x00603000
b
ptr
0x00603004
len
0x05
cap
0x05
byte 0
8
16
24
Heapbase = 0x00603000
9 / 32 bytes used23 free
+0
+8
+10
+18
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
*a
42
*b
"hello"
byte 0
8
16
24

Type Palette — drag onto the stack

bool1B
i81B
char4B
i324B
f324B
i648B
f648B
Box<i32>8B + heap
String24B + heap
Vec<i32>24B + heap
integer types
floating point
character
boolean
owned (heap-backed)

Declared bindings

TypeNameValueStack addrStackHeap
Box<i32>a420x7FFFFFE08 B4 B
Stringbhello0x7FFFFFE824 B5 B
Try this:Click Worked example. Notice that a: Box<i32> stores an 8-byte pointer on the stack and just 4 bytes on the heap — the value lives over there. b: String is the heaviest: 24 bytes of stack record (pointer + length + capacity) plus the actual UTF-8 bytes on the heap. Together they fill the 32-byte stack exactly. Now clear all and drag an i32 in — it claims 4 stack bytes and zero heap. Then drag a char — it claims 4 bytes too, not 1. Rust's char is a Unicode scalar value, not a byte.

For each task: read the question, make a prediction, then use the lab to check. Only expand the explanation once you’ve done both. Predicting before checking is what builds intuition; reading the answer first builds none.

In C and C++, a char is one byte. In Rust, hover over the char chip in the palette. The badge says 4B. Clear the stack (using “Clear All”) and drag a char onto it, which gives it the value 'A'. How many stack bytes does it claim, and why isn’t it 1?

Show explanation

A Rust char is always 4 bytes because it stores a Unicode scalar value (not a byte). A Unicode scalar value is any code point from U+0000 to U+10FFFF (excluding the surrogate range). The largest of those needs 21 bits, so 4 bytes is the smallest fixed-width container that can hold any of them.

This means a Rust char can directly hold characters like '日' (which can mean sun/day/daytime), '🦀', or 'é' (single code points that wouldn’t fit in a C char). Try editing your binding and giving it the value '日' (by cutting and pasting it from here). The stack still uses exactly 4 bytes (the same as for 'A'). The size is fixed; only the bit pattern inside those 4 bytes changes.

For raw byte data, Rust uses u8 (1 byte) instead of char. And for UTF-8 strings of variable length, you reach for String or &str. Mixing these up is one of the most common stumbles for C and C++ programmers learning Rust (as the type system actively pushes you to keep the concepts of “byte,” “Unicode scalar,” and “string” separate).

Click Worked example to restore the starting state. Look at a: Box<i32>. The bindings table shows it claims 8 bytes on the stack and 4 bytes on the heap. But an i32 is only 4 bytes. Why does the stack show 8?

Predict the answer, then click on a in the stack strip and look at what’s drawn inside the block (it says → heap).

Show explanation

Box<i32> is a pointer to an i32 on the heap. The 8 bytes on the stack are the pointer itself (a 64-bit address). The 4 bytes on the heap are the actual i32 value (42 in this case, the worked-example default).

So Box doesn’t contain its T; it points at it. On a 64-bit machine, every pointer is 8 bytes, regardless of what it points to. A Box<bool> would also be 8 bytes on the stack (even though bool is only 1 byte on the heap). A Box<[u8; 1_000_000]> would still be 8 bytes on the stack, with a million bytes on the heap.

This pattern (fixed stack cost, variable heap cost) is the entire reason heap allocation exists. The stack only knows about the pointer’s size, which is constant. The big or oddly-sized data lives over there, on the heap, behind the pointer. The → heap label drawn inside the stack block is a visual cue that this binding’s real value isn’t here on the stack (follow the pointer).

A String is heavier still. Looking at the worked example, b: String claims 24 bytes on the stack plus 5 bytes on the heap (for "hello"). Why 24 bytes and not 8?

Edit b and change its value from hello to a longer word (e.g., hello world). Watch what happens to the stack count and the heap count.

Show explanation

A String’s stack record is three machine words: a pointer to the heap buffer (8 bytes), a length (8 bytes, indicating how many bytes of the buffer are in use), and a capacity (8 bytes, indicating how many bytes the buffer can hold before it has to grow). 8 + 8 + 8 = 24 bytes. This is what’s known informally as a fat pointer (a pointer that carries extra metadata alongside it).

When you changed "hello" to "hello world", the stack stayed at 24 bytes (as the fat-pointer record is fixed-shape and doesn’t care how long the string is). The heap portion grew from 5 to 10 bytes, because that’s where the actual UTF-8 text lives.

Vec<T> has exactly the same 24-byte stack layout for the same reason: pointer + length + capacity. A Vec<i32> of 100 elements has the same 24-byte stack record as one with 5 elements; only the heap allocation differs.

The reason String carries both length and capacity (rather than just length) is to support efficient growth. When you push a character, if there’s spare capacity, the string just writes into the existing heap buffer. If not, it allocates a bigger buffer, copies, and updates the pointer and capacity. The length is what s.len() returns; the capacity is invisible to most code but is what makes push cheap on average.

Note that in this simulation the length and capacity are shown in hexadecimal, so the length has grown to 0x0B hexadecimal which is equal to 11 decimal. This is a more realistic representation. Remember too that, unlike C/C++, there is no hidden \0 character at the end of the string — it is no required, as the length of the string is known.

Clear the stack. Without using the lab, predict the stack and heap costs for each of the following bindings:

let x: i64 = 42;
let y: Vec<i32> = vec![1, 2, 3];
let z: Box<f64> = Box::new(3.14);

Now drag the corresponding chips onto the stack and verify your predictions against the bindings table. Which binding is the heaviest on the stack? Which uses the most heap?

Show explanation

The expected costs:

BindingStackHeap
x: i6480
y: Vec<i32>24(3 × 4) = 12
z: Box<f64>88

The heaviest stack record is y: Vec<i32> at 24 bytes (as a fat pointer is always 24 bytes, regardless of element type or count). x and z are equal at 8 bytes each, but for completely different reasons: x is a value (an i64), while z is a pointer to a value (8-byte address).

The largest heap allocation here is y at 12 bytes (three i32 elements, each 4 bytes). z uses 8 bytes of heap (one f64). x uses no heap at all.

The Vec is a useful example because it shows both faces of the cost story at once: it’s the fattest stack record and it owns variable heap data whose size depends on the elements. This is the general shape of all owned heap-backed types in Rust (a fixed-shape record on the stack and an arbitrarily-large allocation on the heap).

In the next lab, when we start moving and borrowing these bindings, the stack record is what gets copied or referenced; the heap allocation stays put. That’s why the distinction between the two parts matters so much.


You’ve now seen, by direct manipulation of bytes, that:

  • Every Rust binding has a fixed-size stack footprint, decided by its type at compile time.
  • Primitives (i32, f64, bool, char, …) are fully stack-resident. Their values live directly in the stack bytes, no heap involved.
  • Rust’s char is 4 bytes (not 1). It stores a Unicode scalar value, not a byte.
  • Owned heap-backed types (Box<T>, String, Vec<T>) have a two-part footprint: a fixed-shape record on the stack plus a separate allocation on the heap.
  • Box<T> is the simplest of these: 8 bytes of pointer on the stack, sizeof(T) bytes on the heap.
  • String and Vec<T> are fat pointers: 24 bytes on the stack (pointer + length + capacity) plus an arbitrarily-large heap buffer. Editing the value changes the heap part but never the stack part.
  • The stack and the heap live at very different addresses. The stack record doesn’t physically contain the heap data; it points at it.

In the next lab (Ownership), you’ll start moving these bindings around. You’ll see what happens when one binding is assigned to another, why some moves are cheap and some are forbidden, and why the two-part footprint makes Rust’s ownership rules natural rather than arbitrary.

Knowledge Check

A binding `let s: String = String::from("hello");` is declared. How many bytes does the stack record occupy?

Knowledge Check

Why does Rust's `char` take 4 bytes when C's `char` only takes 1?

Knowledge Check

A `Box<bool>` and a `Box<[u8; 1024]>` have the same stack cost. True or false, and why?

Knowledge Check

Which of the following Rust types put data on the heap? (Select all that apply.)