Why this matters
Text is everywhere: input, output, file names, messages, configuration. Rust has two main string types, String and &str, and newcomers are often confused about which to use. With modules 4 and 5 behind you, the distinction is simple: String owns its text, &str borrows it. This lesson makes that precise, shows how to convert between them, and covers building and joining strings.
Two types, one idea
String | &str | |
|---|---|---|
| what | owned, growable text buffer | borrowed view of some text |
| memory | handle (ptr, len, cap) + heap buffer | fat pointer (ptr, len) into someone else's bytes |
| size of the handle | 24 bytes | 16 bytes |
| can grow or change | yes (if mut) | no |
| created by | String::from, to_string, format!, read_line into it | literals, slicing, borrowing a String |
This is the same relationship as Vec<T> and &[T] (module 9): an owned growable buffer, and a slice borrowing part of it. Both always hold valid UTF-8 (next lesson).
let lit: &str = "hi"; let s: String = String::from("hello"); let part: &str = &s[1..4];
lit: ptr --+ s: ptr ------------+ part: ptr --------+
len 2 | len 5 | len 3 |
v cap 5 v v
static data: [h|i] heap: [h | e | l | l | o] (points at 'e')
String literals
A literal like "hi" is stored in the executable's read-only data, loaded at startup and valid for the whole run. Its type is &'static str: a borrowed view of bytes that live forever ('static). That is why you can return a literal from a function with no ownership worries, and why literals cannot be modified.
Converting
fn main() {
let a: String = String::from("one");
let b: String = "two".to_string();
let c: String = "three".to_owned();
let d: &str = &a;
let e: &str = a.as_str();
let f: &str = &b[..2];
println!("{a} {b} {c} {d} {e} {f}");
}
one two three one one tw
&strtoString:String::from(x),x.to_string(),x.to_owned(). All allocate and copy the bytes.Stringto&str:&s(automatic where a&stris expected),s.as_str(), or a slice&s[a..b]. These are free: just a pointer and a length into the existing buffer.
The automatic &String to &str conversion is called deref coercion. It is why a function with a &str parameter accepts &my_string:
fn count_spaces(s: &str) -> usize {
s.matches(' ').count()
}
fn main() {
let owned = String::from("a b c");
println!("{}", count_spaces(&owned));
println!("{}", count_spaces("x y"));
}
2
1
Guideline: accept &str in parameters, return String when you create new text, store String in structs (owned data, module 7).
Building strings
A String grows like the buffer you saw in module 4:
fn main() {
let mut s = String::with_capacity(16);
s.push_str("abc");
s.push('d');
s.insert(0, '>');
s += "ef";
println!("{s} len={} cap={}", s.len(), s.capacity());
s.truncate(3);
println!("{s}");
s.clear();
println!("{:?} empty={}", s, s.is_empty());
}
>abcdef len=7 cap=16
>ab
"" empty=true
push_str appends a &str, push a single char, += is push_str. with_capacity preallocates when you know roughly how big the result will be.
Joining strings: +, format! and join
The + operator has an ownership twist:
fn main() {
let first = String::from("Hello");
let second = String::from("world");
let joined = first + ", " + &second;
println!("{joined}");
println!("{second}");
}
Hello, world
world
a + b requires an owned String on the left and a &str on the right. It takes ownership of first, appends to its buffer (reusing it, no new allocation if there is room) and returns it. So first is moved and cannot be used afterwards, while second was only borrowed. Two strings cannot be added as &str + &str:
fn main() {
let a = "Hello, ";
let b = "world";
let c = a + b;
}
error[E0369]: cannot add `&str` to `&str`
--> main.rs:4:15
|
4 | let c = a + b;
| - ^ - &str
| | |
| | `+` cannot be used to concatenate two `&str` strings
| &str
|
= note: string concatenation requires an owned `String` on the left
help: create an owned `String` from a string reference
|
4 | let c = a.to_owned() + b;
| +++++++++++
The compiler explains that &str values cannot be added and suggests .to_owned() on the left side.
For anything beyond two pieces, format! is clearer: it borrows all its arguments and builds a new String:
fn main() {
let name = String::from("Ada");
let year = 1843;
let msg = format!("{name} published in {year}; {name} was 27");
println!("{msg}");
let parts = ["a", "b", "c"];
println!("{}", parts.join("-"));
println!("{}", "ab".repeat(3));
}
Ada published in 1843; Ada was 27
a-b-c
ababab
join on a slice of strings inserts a separator between elements, which neatly solves the "spaces between items but not after the last" problem from module 3.
Comparing strings
== compares contents and works across the two types: String == &str, &str == String. Ordering comparisons (<) compare byte by byte, which for ASCII is alphabetical with uppercase before lowercase. eq_ignore_ascii_case compares ignoring ASCII case.
fn main() {
let s = String::from("apple");
println!("{} {} {}", s == "apple", "apple" < "banana", "Zebra" < "apple");
println!("{}", "HeLLo".eq_ignore_ascii_case("hello"));
}
true true true
true
Common mistakes
&String parameters: work, but refuse literals. Use &str.
Expecting + to leave the left side usable: it moves it. Use format! when you need to keep both.
Returning &str to new text: a function that builds text must return String (module 5's dangling references). fn make() -> &str { &format!("x") } fails with E0515.
Mismatched types: passing String where &str is expected gives E0308 with the help "consider borrowing here: &name". Passing &str where String is expected needs .to_string().
Recap
Stringowns a growable UTF-8 buffer on the heap;&strborrows UTF-8 bytes owned elsewhere.- Literals are
&'static strin the executable's static data. String::from,to_string,to_ownedallocate;&s,as_str(), slicing are free.- Take
&strparameters; return and storeString. +moves its left operand and borrows its right;format!andjoinborrow everything.
Going further: look up String::from_utf8 and str::as_bytes in the docs, the bridge between strings and raw bytes that the next lesson explores.