Strings and str
Intermediate · Abstractions
What & why
Rust has two main text types and beginners bump into both on day one: String (text your program owns and can grow) and &str (a borrowed view into some text). Once you see why there are two, the endless “expected &str, found String” errors stop being mysterious and start being obvious.
The idea, slowly
Owned vs borrowed, one more time
You already met this split in the Ownership lessons, just with different types. Text is the same story:
Stringis a growable, heap-allocated buffer your variable owns. Think of it as a whiteboard you bought — it’s yours, you can write more on it, erase it, and when you’re done it gets thrown away.&str(say “string slice”) is a borrowed look at text that already exists somewhere. Think of it as pointing at words on someone else’s whiteboard — you can read them, but you don’t own the board and can’t grow it.
fn main() {
let owned: String = String::from("hello"); // owns a growable buffer
let borrowed: &str = &owned; // borrows a view of it
println!("owned = {}", owned);
println!("borrowed = {}", borrowed);
}
&owned borrows the String and hands you a &str looking into it. Nothing is copied; borrowed just points at the same letters owned holds.
String literals are already &str
Every time you type text in quotes, that’s a &str — it points into your compiled program, which stays alive the whole time it runs:
fn main() {
let greeting = "hi there"; // type is &str, no String involved
println!("{}", greeting);
}
So "hi" is a &str, and String::from("hi") turns that borrowed text into an owned String you can grow.
Growing a String
Only String can grow, because only String owns its buffer:
fn main() {
let mut name = String::from("Shamir");
name.push_str("ul"); // add several chars
name.push('!'); // add one char (note single quotes)
println!("{}", name); // Shamirul!
}
push_str takes a &str (a borrowed piece of text to append), and push takes a single char. Try this on a plain &str and it won’t compile — a borrowed view has nothing of its own to grow.
The function-argument rule of thumb
This is the practical payoff. When a function just needs to read text, take &str. It’s the more flexible choice because both a String and a &str can be passed to it:
fn shout(text: &str) -> String {
text.to_uppercase()
}
fn main() {
let owned = String::from("hello");
println!("{}", shout(&owned)); // pass a String by reference -> &str
println!("{}", shout("world")); // pass a literal &str directly
}
shout accepts &str, so it works for owned strings (via &owned) and literals. If you’d written fn shout(text: String), you’d force every caller to hand over an owned String and give it away. Taking &str is friendlier. Take &str to read; return String when you build new text.
Length is in bytes, not letters
This one surprises everyone. Rust text is UTF-8, where some characters take more than one byte. .len() counts bytes:
fn main() {
let word = "café";
println!("bytes: {}", word.len()); // 5, not 4 — é is 2 bytes
println!("chars: {}", word.chars().count()); // 4 actual characters
}
Because of this, you also can’t index text by number — word[0] is a compile error in Rust, on purpose, because “byte 0” and “character 0” aren’t always the same thing. To walk characters, use .chars().
Common mistakes
expected &str, found String(or vice versa). A function wanting&strwon’t silently take aString. Pass&my_stringto borrow it down to a&str. Going the other way, turn a&strinto aStringwith.to_string()orString::from(...).- Trying to grow a
&str.push_str/pushneed an owned buffer, so they only exist onString. The fix is to start from aString, or convert with.to_string(). - Indexing text with
[i].s[0]doesn’t compile for strings because byte positions and character positions differ in UTF-8. Use.chars().nth(i)for a character, or slice by a known byte range. - Assuming
.len()is the character count. It’s the byte count. For visible characters use.chars().count(). - Taking
Stringas a parameter when you only read it. This forces callers to give up ownership for no reason. Prefer&strfor read-only text arguments.
More examples
Cleaning up user input
Form fields arrive messy — stray whitespace, inconsistent casing. Normalize them before you compare or store them.
fn main() {
let raw_input = " Alice@Example.com \n";
let clean = raw_input.trim().to_lowercase();
println!("clean email: '{}'", clean);
}
Splitting a CSV-like line
Config files and simple data dumps are often just comma-separated fields. .split() plus .trim() handles the common case without pulling in a CSV crate.
fn main() {
let line = "Ferris, 8, Crab";
let fields: Vec<&str> = line.split(',').map(|f| f.trim()).collect();
println!("{:?}", fields); // ["Ferris", "8", "Crab"]
}
Router-style path matching
A tiny web framework has to decide which handler owns a request path. starts_with/ends_with are the bread and butter of that decision.
fn main() {
let path = "/api/users/42";
if path.starts_with("/api/users/") {
println!("route: get user");
} else if path.ends_with(".json") {
println!("route: serve json file");
} else {
println!("route: not found");
}
}
Building a receipt line by line
Sometimes you don’t have all the text up front — you build it as you go, like assembling a shopping list into one printable line.
fn main() {
let items = vec!["eggs", "milk", "bread"];
let mut receipt = String::new();
for item in &items {
receipt.push_str(item);
receipt.push_str(", ");
}
println!("{}", receipt); // eggs, milk, bread,
}
A helper that formats log lines
Utility functions like this get called with all kinds of text — owned Strings built at runtime, and &str literals. Taking &str parameters means one function serves both.
fn log_line(level: &str, message: &str) -> String {
format!("[{}] {}", level.to_uppercase(), message)
}
fn main() {
let msg = String::from("server started");
println!("{}", log_line("info", &msg));
println!("{}", log_line("warn", "disk almost full"));
}
Your turn
This function should return the text in uppercase, and be callable with both a String and a literal. It doesn’t compile. Fix the parameter type.
fn loud(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("rust");
println!("{}", loud(&name)); // passing &name (a &str) — type mismatch
println!("{}", loud("go")); // passing a literal &str — type mismatch
}
Show solution
main passes borrowed text (&name and the literal "go"), both of which are &str. Make the function accept &str:
fn loud(text: &str) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("rust");
println!("{}", loud(&name));
println!("{}", loud("go"));
}
Accepting &str lets the function read either an owned String (borrowed with &) or a literal, without taking ownership.
Quick check
Remember this
Stringowns growable text;&strborrows a view of existing text.- String literals like
"hi"are already&str. - Only
Stringcan grow (push_str,push) — a&strhas nothing of its own to grow. - For read-only text arguments, take
&str; it accepts bothString(via&) and literals. .len()is bytes, not characters; you can’t index text by number — use.chars().
Go deeper
- Rust Book - Storing UTF-8 Encoded Text with Strings — How Rust treats text.
Next: