Skip to content
← All articles

String vs &str, part 1: the memory model

The most confusing early topic in Rust, and the reason is that people try to memorise conversion incantations before they have the picture. Here is the picture — and it is the same picture as Vec and slices, which you already have.

Ask a room of Rust beginners what confused them most in month one and “String versus &str” wins. Ask why and you get a list of incantations: .to_string(), .as_str(), &*, String::from, .to_owned(), .into(), and an unhappy shrug.

That list is the symptom. The cause is trying to memorise conversions before having the memory model. So put the conversions down for ten minutes. There is one picture, you already have it from the previous two items, and everything else is a corollary.

The picture you already have

You just learned this pair:

  • Vec<i32> — owns a heap buffer. The value itself is three machine words: a pointer, a length, and a capacity. 24 bytes.
  • &[i32] — a view of somebody else’s run of i32. Two machine words: a pointer and a length. 16 bytes. Owns nothing, frees nothing.

Now the whole of this item:

  • String — owns a heap buffer of UTF-8 bytes. Pointer, length, capacity. 24 bytes.
  • &str — a view of somebody else’s run of UTF-8 bytes. Pointer and length. 16 bytes. Owns nothing, frees nothing.
String : Vec<T>  ::  &str : &[T]

That proportion does most of the teaching for free. String is Vec<u8> with a guarantee bolted on — that the bytes are valid UTF-8 — and &str is &[u8] with the same guarantee. The guarantee is why they are separate types rather than aliases: String will not let you push an arbitrary byte and break it.

These sizes are measured, not approximate. On the 64-bit machines that grade this course:

type size_of what it is
String 24 ptr + len + capacity
&str 16 ptr + len (a fat pointer)
Vec<i32> 24 ptr + len + capacity
&[i32] 16 ptr + len
&i32 8 just a pointer
💡&str is 16 bytes and &i32 is 8. Both are "a reference". Why does one need twice the space? click to reveal

Because str is an unsized type — its size is not known at compile time — and a reference to an unsized type has to carry the missing information alongside the address.

i32 is always four bytes, so &i32 needs only “where”. str is “some number of UTF-8 bytes”, so &str needs “where” and “how many”. That second word is what makes it a fat pointer.

The same is true of &[T] versus &T, and it is the reason you cannot have a bare str or a bare [T] as a local variable: the compiler would not know how much stack to reserve. You always meet them behind a reference (&str, &[T]), behind a box (Box<str>, Box<[T]>), or wrapped in an owner (String, Vec<T>).

The third kind of fat pointer, &dyn Trait, carries a pointer to a vtable instead of a length. Same idea: the reference carries what the type erased.

Where the bytes live

Three storage stories, and knowing which one you have explains most lifetime questions later.

A string literal lives in the binary.

let s: &'static str = "hello";

Those five bytes are baked into the executable’s read-only data by the compiler. Nothing allocates at run time; s is a pointer into your own program image plus the number 5. Its lifetime is 'static — it lives as long as the process — which is why literals can be returned from anywhere without complaint.

A String owns a heap allocation.

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

Now there is a heap block containing h e l l o, and a 24-byte value on the stack pointing at it with len = 5 and capacity at least 5. Pushing more bytes may reallocate. When s goes out of scope its Drop runs and the block is freed — exactly once, by exactly one owner. That is the whole memory-management story, and there is no garbage collector anywhere in it.

A &str points into either.

let owned = String::from("hello world");
let view: &str = &owned[0..5];      // points INTO the heap block above
let lit: &str  = "hello";           // points into the binary

Both are &str. Neither owns anything. The first one is only valid while owned is — which is precisely the constraint that lifetimes exist to express, and which is why Track 8 will feel motivated rather than arbitrary when you get there.

Why the split exists at all

Languages with one string type pay for it somewhere. Java strings are immutable and every concatenation allocates. Python strings are immutable with an interning cache. C strings are a pointer and a prayer about a NUL byte.

Rust’s split buys three things:

Substrings are free. &s[6..11] allocates nothing and copies nothing — it is a new pointer and a new length into the same bytes. In a language with only owned strings, taking a substring means copying it.

Function signatures can be honest about ownership. fn f(s: &str) says “I will read this and not keep it”. fn f(s: String) says “give it to me; I am keeping it or consuming it”. You can see the contract without reading the body, and the compiler enforces it.

The caller is not forced to allocate. A function taking &str can be called with a literal, with a whole String, with a slice of one, with text parsed out of a buffer — none of which requires building a fresh String first.

💡fn greet(name: &String) compiles fine. Clippy's ptr_arg lint rejects it anyway. Give the argument in terms of what each type actually is. click to reveal

A &String is a pointer to the 24-byte header, from which you must follow another pointer to reach the text. A &str is the pointer and the length. So the &str version is one indirection cheaper — but that is the small half of the argument.

The large half is about callers. Everything you can do through a &String — read it, iterate it, slice it, ask its length — is available through a &str. But &str accepts strictly more callers:

greet("world");            // a literal          — works for &str, NOT for &String
greet(&owned);             // a String           — works for both (deref coercion)
greet(&owned[6..11]);      // a substring        — works for &str, NOT for &String

Taking &String costs you every caller who does not happen to own a whole String in exactly that shape, and buys you nothing at all. The identical argument applies to &Vec<T> versus &[T], which is why ptr_arg covers both — and why meeting it first on Vec in item 1.20 makes it easy to accept here.

The conversions, now that they mean something

With the picture in place these stop being incantations and become obvious:

// &str  ->  String   (allocates and copies: you are creating an owner)
let a = "hello".to_string();
let b = String::from("hello");
let c: String = "hello".into();
let d = "hello".to_owned();

// String  ->  &str   (free: you are handing out a view)
let e: &str = &owned;            // deref coercion, the usual way
let f: &str = owned.as_str();    // explicit, when inference needs help
let g: &str = &owned[..];        // slice the whole thing

The four in the first group are the same operation with different names, and the differences are conventional rather than technical. to_string() goes through Display and is the one to use on anything printable. String::from is the most explicit. .into() is for when the target type is already obvious from context. .to_owned() is the general “give me the owned form of this borrowed thing” and is what generic code uses.

All four allocate. That is not a flaw; it is what “create an owner” means. The direction that is free is String to &str, because handing out a view costs nothing.

Deref coercion is the quiet machinery in let e: &str = &owned;. String implements Deref<Target = str>, so wherever a &str is expected and you supply a &String, the compiler inserts the conversion. This is also why you can call every str method directly on a Stringowned.len(), owned.trim(), owned.contains("ell") are all str methods reached through the deref. Exactly the same relationship holds between Vec<T> and [T], which is why words.join(" ") works on a Vec<String> even though join is defined on slices.

What to carry into the next two items

Four sentences.

  1. String owns; &str views. That is the entire distinction.
  2. String : Vec<T> :: &str : &[T]. Anything true of one pair is true of the other.
  3. Take &str, return String, store String.
  4. Going from owned to borrowed is free; going the other way allocates.

Item 1.23 makes those into muscle memory, with ptr_arg doing the enforcing. Item 1.24 then shows you what the bytes underneath actually are, which is where UTF-8 stops being a footnote.