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 ofi32. 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 String — owned.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.
-
Stringowns;&strviews. That is the entire distinction. -
String : Vec<T> :: &str : &[T]. Anything true of one pair is true of the other. -
Take
&str, returnString, storeString. - 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.