Skip to content

The anatomy of a String

Level: 101 → 201 · working knowledge

One line: A String is three words on the stack — pointer, length, capacity — and the text itself on the heap. len is what you have, capacity is what you paid for, and the gap between them is why growth is cheap.

let s1 = String::from("Hello");

        stack                          heap
  ┌───────────┬────┐
  │ ptr       │  ●─┼──────────▶ ┌───┬───┬───┬───┬───┐
  │ len       │  5 │            │ H │ e │ l │ l │ o │
  │ capacity  │  5 │            └───┴───┴───┴───┴───┘
  └───────────┴────┘

Those three words are the whole fixed-size part — which is why size_of::<String>() is 24 on a 64-bit machine no matter how long the text is, and why moving a String moves three words, never the bytes. A &str is the same picture minus capacity: a pointer and a length, no ownership, no room to grow.


Born empty, and free

let mut s = String::new();
println!("{} {}", s.len(), s.capacity());   // 0 0

Capacity zero means no heap allocation has happened at all. The pointer is a well-known dangling-but-valid placeholder; the first push buys the real buffer. Creating empty Strings is free, which is why String::new() in a struct initializer costs nothing to write.

Growth runs ahead of you

push_str appends bytes. When len would pass capacity, the String allocates a bigger buffer — roughly double — and copies the bytes over. Watch the capacity column in the verified output: 0 → 8 → 16 → 32, while len creeps. The exact numbers are the allocator's choice; the shape is the contract. Doubling is what makes a build-by-appending loop cheap: most pushes land in room already bought, and the occasional copy is amortized away.

Two consequences worth owning:

  • Knowing the size beats discovering it. String::with_capacity(n) buys once; every push then lands without a reallocation. reserve(n) does the same mid-flight, and shrink_to_fit() hands unused room back.
  • Capacity is bookkeeping, not content. Two Strings with the same text are equal whatever their capacities; ==, hashing and printing read ptr and len only.

Why the borrow checker cares

Growth may move the bytes — the old buffer is freed and the text lives at a new address. Which is exactly why this refuses to compile:

let mut s = String::from("STAR");
let view = &s[..2];      // a &str into s's buffer
s.push_str(" voting");   // may reallocate — the buffer can move
println!("{view}");
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable

Without that rule, view would point into freed memory the moment a push reallocates. The borrowing rules are not string-specific — but a growable buffer is where they earn their keep most visibly.

A Vec<u8> that promises UTF-8

A String is not "a Vec of characters" — the second tutorial trap this page exists for. It is a Vec<u8>: the heap holds bytes, and one character may span several of them (Meet the char takes that on). Everything Vec taught you transfers:

Vec<T> String
frees its buffer on drop yes yes
::new() · ::with_capacity() yes yes
.len() · .capacity() · .reserve() yes yes
push / pop one T one char (push_str for text)
a slice of it &v[a..b] is &[T] &s[a..b] is &str
what the bytes must be anything valid UTF-8, always

The last row is the entire difference, and it is enforced at the door:

String::from_utf8(vec![83, 84, 65, 82])   // Ok("STAR")
String::from_utf8(vec![83, 84, 0xFF])     // Err: invalid utf-8 sequence …

Checked once on the way in, never again inside — which is what lets every method on str assume the bytes are sound.

If you are coming from another language

Python. You already know this growth pattern — from list, not str.

Python Rust
list.append amortized O(1) by over-allocating push_str, same strategy
s += "x" in a loop builds a new str each time — text is immutable appends in place, occasionally reallocating
sys.getsizeof(lst) jumps in steps the over-allocation, visible .capacity(), queryable directly

What changes: Python hides the buffer entirely (there is no capacity() on a list, and strings refuse to grow at all); Rust puts the bookkeeping on the type and lets you pre-pay with with_capacity.

ABAP. An internal table is the growable buffer you already manage by hand.

ABAP Rust
DATA itab TYPE TABLE OF … INITIAL SIZE n pre-allocating for a known load String::with_capacity(n)
APPEND growing the table in blocks the kernel over-allocates for you doubling growth
lv = lv && lv_more concatenation builds new text push_str appends in place

What changes: ABAP's allocation strategy is the kernel's business and invisible; Rust's is one method call away (capacity()), and the compiler — not a dump — is what stops a stale reference into a moved buffer.


Practice

Predict, then verify. Write down a table of len and capacity after each step: String::new(), then push_str of "STAR", " voting", " is score", " + runoff". Which pushes reallocate?

Then build the same text a second time with String::with_capacity, sized so that no push reallocates, and prove the two builds are == even though their capacities differ.

Solution

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

//! Kata solution: predict len and capacity through five operations,
//! then make one allocation serve all of them.
//!
//!   rustc --edition 2024 anatomy_of_a_string_kata.rs -o /tmp/anatk && /tmp/anatk

fn row(step: &str, s: &String, note: &str) {
    println!("   {step:<26} len {:>2}  capacity {:>2}   {note}", s.len(), s.capacity());
}

fn main() {
    println!("Round 1 — the naive build, watching the buffer move");
    let mut s = String::new();
    row("String::new()", &s, "no heap buffer at all");
    s.push_str("STAR");
    row("push_str(\"STAR\")", &s, "first allocation");
    s.push_str(" voting");
    row("push_str(\" voting\")", &s, "outgrew it — reallocated, bytes moved");
    s.push_str(" is score");
    row("push_str(\" is score\")", &s, "outgrew it again");
    s.push_str(" + runoff");
    row("push_str(\" + runoff\")", &s, "fits — no reallocation this time");

    println!("\nRound 2 — the same text, one allocation, because we knew the size");
    let mut planned = String::with_capacity(29);
    row("with_capacity(29)", &planned, "bought once, up front");
    for piece in ["STAR", " voting", " is score", " + runoff"] {
        planned.push_str(piece);
    }
    row("four push_str calls", &planned, "capacity never changed");

    println!("\nRound 3 — the two builds are equal; capacity is not content");
    println!("   s == planned?  {}   (capacities {} vs {})",
        s == planned, s.capacity(), planned.capacity());

    println!("\nThe rule to carry away:");
    println!("   len is the text, capacity is the room. Growth doubles so that");
    println!("   repeated pushes stay cheap; with_capacity skips the moves when");
    println!("   you can name the size; shrink_to_fit hands the spare room back.");
}

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

Round 1 — the naive build, watching the buffer move
   String::new()              len  0  capacity  0   no heap buffer at all
   push_str("STAR")           len  4  capacity  8   first allocation
   push_str(" voting")        len 11  capacity 16   outgrew it — reallocated, bytes moved
   push_str(" is score")      len 20  capacity 32   outgrew it again
   push_str(" + runoff")      len 29  capacity 32   fits — no reallocation this time

Round 2 — the same text, one allocation, because we knew the size
   with_capacity(29)          len  0  capacity 29   bought once, up front
   four push_str calls        len 29  capacity 29   capacity never changed

Round 3 — the two builds are equal; capacity is not content
   s == planned?  true   (capacities 32 vs 29)

The rule to carry away:
   len is the text, capacity is the room. Growth doubles so that
   repeated pushes stay cheap; with_capacity skips the moves when
   you can name the size; shrink_to_fit hands the spare room back.

Five bindings, two buffers. Without running anything, fill in a table for the five names below: where each handle lives, where the bytes it reaches live, and how big the handle is. Then check yourself — the sizes with size_of, divided by size_of::<usize>() so the answer is in machine words and holds on a 32-bit target too, and the sharing with as_ptr(). Which of the five reach the same bytes? Do a and b, which hold equal text?

fn main() {
    let a: &str = "hello";
    let b: String = String::from("hello");
    let c: &String = &b;
    let d: &str = b.as_str();
    let e: &&str = &a;
}
Solution

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

//! Kata solution: five bindings, two buffers — which names own bytes, which
//! point at bytes something else owns, and how big each handle is.
//!
//!   rustc --edition 2024 five_bindings_kata.rs -o /tmp/fbk && /tmp/fbk

use std::mem::size_of;

/// A handle's size in machine words. Words, not bytes, because this is the
/// number that stays the same on a 32-bit target: the bytes would halve.
fn words<T>(_: &T) -> usize {
    size_of::<T>() / size_of::<usize>()
}

fn main() {
    let a: &str = "hello";
    let b: String = String::from("hello");
    let c: &String = &b;
    let d: &str = b.as_str();
    let e: &&str = &a;

    println!("1. Five handles, measured in machine words");
    println!("   {:<5} {:<9} {:>5}   what the handle holds", "name", "type", "words");
    let rows: [(&str, &str, usize, &str); 5] = [
        ("a", "&str", words(&a), "the address of the bytes, and their length"),
        ("b", "String", words(&b), "address, length and capacity of its own buffer"),
        ("c", "&String", words(&c), "the address of b: a pointer to the pointer"),
        ("d", "&str", words(&d), "the address of b's bytes, and a length"),
        ("e", "&&str", words(&e), "the address of a"),
    ];
    for (name, ty, w, what) in rows {
        println!("   {name:<5} {ty:<9} {w:>5}   {what}");
    }
    assert_eq!([words(&a), words(&b), words(&c), words(&d), words(&e)], [2, 3, 1, 2, 1]);

    println!();
    println!("2. Two buffers behind five names");
    println!("   a and e reach the same bytes        {}", a.as_ptr() == e.as_ptr());
    println!("   b, c and d reach the same bytes     {}", b.as_ptr() == c.as_ptr() && c.as_ptr() == d.as_ptr());
    println!("   a and b hold equal text             {}", a == b);
    println!("   a and b hold it in the same bytes   {}", a.as_ptr() == b.as_ptr());
    println!("   b's buffer: capacity {} for its {} bytes", b.capacity(), b.len());

    println!();
    println!("3. Where each one lives");
    println!("   All five handles are locals, so all five are on the stack. Only two");
    println!("   copies of the bytes exist: \"hello\" in the program's read-only data,");
    println!("   which a and e reach, and the copy on the heap that b owns and that c");
    println!("   and d reach. Drop b, and any later use of c or d stops compiling;");
    println!("   a and e do not notice, because a literal lasts as long as the program.");
}

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

1. Five handles, measured in machine words
   name  type      words   what the handle holds
   a     &str          2   the address of the bytes, and their length
   b     String        3   address, length and capacity of its own buffer
   c     &String       1   the address of b: a pointer to the pointer
   d     &str          2   the address of b's bytes, and a length
   e     &&str         1   the address of a

2. Two buffers behind five names
   a and e reach the same bytes        true
   b, c and d reach the same bytes     true
   a and b hold equal text             true
   a and b hold it in the same bytes   false
   b's buffer: capacity 5 for its 5 bytes

3. Where each one lives
   All five handles are locals, so all five are on the stack. Only two
   copies of the bytes exist: "hello" in the program's read-only data,
   which a and e reach, and the copy on the heap that b owns and that c
   and d reach. Drop b, and any later use of c or d stops compiling;
   a and e do not notice, because a literal lasts as long as the program.

The verified output

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

1. String::new() allocates nothing
   String::new()                  len  0   capacity  0

2. The first push buys a buffer; growth roughly doubles
   push_str("aa")                 len  2   capacity  8
   push_str("bbbbbbb")            len  9   capacity 16
   push_str("cccccccccc")         len 19   capacity 32
   (the exact numbers are the allocator's choice — the SHAPE is the
    lesson: capacity jumps ahead of len, so most pushes cost nothing)

3. Paying up front: with_capacity
   String::with_capacity(32)      len  0   capacity 32
   push_str("scores: 5, 4, 3")    len 15   capacity 32
   push_str(", 2, 1, 0")          len 24   capacity 32
   two pushes, zero reallocations — the buffer never moved

4. Giving it back: shrink_to_fit
   shrink_to_fit()                len 24   capacity 24

5. Capacity is bookkeeping, not content
   "hi" with capacity  2 == "hi" with capacity 64?  true

6. A String is a Vec<u8> that promises UTF-8
   from_utf8([83, 84, 65, 82]) = Ok("STAR")
   from_utf8([83, 84, 0xFF])   = Err: invalid utf-8 sequence of 1 bytes from index 2
   the promise is checked at the door, so it never needs checking inside

Run it yourself:

rustc --edition 2024 14_Strings/anatomy_of_a_string/examples/anatomy_of_a_string.rs -o /tmp/anat && /tmp/anat

See also

Po polsku

String to trzy słowa maszynowe na stosie — wskaźnik, długość (len) i pojemność (capacity) — a sam tekst leży na stercie. Dlatego size_of::<String>() wynosi 24 bajty niezależnie od tego, czy trzymasz w nim jedno słowo, czy całą książkę, i dlatego przeniesienie własności (move) kopiuje trzy słowa, nigdy bajtów tekstu. &str to ten sam obrazek bez pojemności: wskaźnik i długość, bez prawa do rozbudowy i bez obowiązku zwolnienia pamięci.

Jeśli miałeś na studiach std::vector z C++, to znasz już połowę tej strony pod polskimi nazwami: size()/capacity(), reserve(), shrink_to_fit() i koszt zamortyzowany przy podwajaniu tablicy dynamicznej. Rust nazywa to tak samo, tylko po angielsku. Jedna rzecz jest tu jednak inna dla polskiego tekstu: pojemność liczy się w bajtach, nie w literach. „zażółć gęślą jaźń” ma siedemnaście znaków, ale zajmuje 26 bajtów — dziewięć liter z ogonkiem lub kreską liczy się po dwa — więc String::with_capacity(17) i tak dokupi bufor po drodze. Rezerwując miejsce z góry, licz bajty gotowego napisu, a nie litery na palcach.

Z tego samego C++ pochodzi też najlepsza intuicja do błędu E0502: unieważnienie iteratorów przy realokacji. push_str może przenieść bufor pod nowy adres i zwolnić stary, a wycinek łańcucha zrobiony wcześniej wskazywałby wtedy w zwolnioną pamięć. W C++ to jest niezdefiniowane zachowanie, które trzeba pamiętać; w Ruscie pożyczanie zamienia to w komunikat kompilatora — cannot borrow s as mutable because it is also borrowed as immutable — i program po prostu się nie buduje.

Ostatnia pułapka jest nazewnicza: String nie jest „wektorem znaków”, tylko Vec<u8> z obietnicą poprawnego UTF-8. Obietnica jest sprawdzana raz, przy wejściu (String::from_utf8 zwraca Err dla śmieci), i właśnie dlatego żadna metoda str nie musi jej sprawdzać ponownie w środku.

Szukaj po polsku: pojemność a długość · tablica dynamiczna koszt zamortyzowany · unieważnienie iteratorów przy realokacji · rust String capacity vs len · rust with_capacity