Strings and Text
Rust's approach to strings surprises many newcomers. Instead of one string type, there are two — and understanding why unlocks a deeper understanding of ownership, memory, and Unicode. Once it clicks, you'll appreciate how much safety Rust gives you for free.
Two String Types
Rust has two primary string types:
String— a heap-allocated, growable, owned string&str— a string slice, a borrowed reference to UTF-8 data
Think of &str like a window into existing string data (which may live in compiled binary or on the heap), while String is a fully owned buffer you can modify.
fn main() {
// &str: a string slice, typically a reference to static data
let greeting: &str = "Hello, world!";
// String: heap-allocated, owned, and growable
let mut name: String = String::from("Alice");
name.push_str(" Smith");
println!("{}", greeting);
println!("{}", name);
// You can get a &str from a String using & or .as_str()
let name_slice: &str = &name;
println!("Slice: {}", name_slice);
}
Creating and Growing Strings
String provides several ways to build and modify text at runtime.
fn main() {
// Different ways to create a String
let a = String::new(); // empty
let b = String::from("hello"); // from literal
let c = "world".to_string(); // via trait method
let d = format!("{} {}", b, c); // formatted
println!("a: {:?}", a);
println!("b: {}", b);
println!("c: {}", c);
println!("d: {}", d);
// Appending to a String
let mut s = String::from("foo");
s.push_str("bar"); // append a &str
s.push('!'); // append a single char
println!("{}", s); // foobar!
// Concatenation with + (moves the left operand)
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 is moved here, s2 is borrowed
println!("{}", s3);
}
String Slices and Indexing
Because Rust strings are UTF-8 encoded, you cannot index them with a plain integer — a single Unicode character can occupy 1 to 4 bytes, so s[0] would be ambiguous.
That sentence is easy to agree with and easy to keep forgetting, because the consequence only shows up on input you probably did not test with. The program below prints four numbers and two strings for two words that look the same length on screen. Work out all six values before you run it:
Predict
Two five-letter-looking words: 'hello' and 'héllo'. The program prints .len() and .chars().count() for each, then takes the first 3 bytes of one and the first 3 chars of the other. Predict all six printed values.
fn main() {
let ascii = "hello";
let accented = "héllo";
// Two strings that LOOK the same length.
println!("ascii len={} chars={}", ascii.len(), ascii.chars().count());
println!("accented len={} chars={}", accented.len(), accented.chars().count());
// A slice by BYTE range that happens to be safe here.
println!("first 3 bytes of ascii: {:?}", &ascii[..3]);
// The same operation, done by characters instead.
let first3: String = accented.chars().take(3).collect();
println!("first 3 chars of accented: {:?}", first3);
}len() returns bytes, not characters — that is its documented contract. "héllo" is 6 bytes but 5 chars, because é is U+00E9 and UTF-8 stores it in two bytes; "hello" is 5 either way, which is exactly why this bug survives an ASCII-only test suite and surfaces the first time a real name arrives. The two questions are genuinely different: .len() answers "how much storage", .chars().count() answers "how much text". The same split governs cutting: &s[..3] is a byte range, while .chars().take(3) is a character count.
fn main() {
let s = String::from("hello");
// Use ranges for byte slices (be careful with multi-byte chars!)
let hello = &s[0..3];
println!("{}", hello); // hel
// Iterate over characters safely
let emoji = "Hello 🦀";
for ch in emoji.chars() {
print!("{} ", ch);
}
println!();
// Iterate over raw bytes
for b in "abc".bytes() {
print!("{} ", b);
}
println!();
// Count characters vs bytes
let japanese = "日本語";
println!("chars: {}", japanese.chars().count()); // 3
println!("bytes: {}", japanese.len()); // 9
}
Note: Slicing with byte ranges that split a multi-byte character will panic at runtime. Always prefer
.chars()or.char_indices()when iterating over text with non-ASCII content.
A panic is the loud version of that mistake, and loud is the good case. The quiet version is worse and much more common: a byte cut that happens to land on a character boundary by luck, returning a real string that is simply the wrong one. The truncation helper below has exactly one bug and produces both failure modes depending on where you cut. It compiles without a warning and its first two checks pass. Commit a hypothesis before you change anything:
Debug
This helper shortens a label for a narrow column. It compiles, and the two ASCII checks pass. The third fails: truncating 'Zoë Washburne' to five gives four letters, not five. Nothing panics, so there is no stack trace to read — say why the count is wrong before you change anything.
// Shorten a label for a narrow column, adding an ellipsis when it is cut.
fn truncate(text: &str, limit: usize) -> String {
if text.len() <= limit {
text.to_string()
} else {
format!("{}...", &text[..limit])
}
}
fn main() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 5), "hello...");
// Same idea, on a name that is not pure ASCII.
let name = truncate("Zoë Washburne", 5);
assert_eq!(name, "Zoë W...", "expected the first five CHARACTERS, got {}", name);
println!("{}", truncate("hello world", 5));
println!("{}", name);
}Expected output: hello...
Zoë W...
The function mixes two units. Both text.len() and &text[..limit] work in bytes, while limit is meant as a count of characters. The first five bytes of "Zoë Washburne" are Z, o, the two bytes of ë, and the space — four characters — so it silently returns "Zoë ...". Nothing panics, nothing warns; you get a real string that is simply the wrong one, and that is the version that survives code review. The loud version is one byte away: truncate("Zoë Washburne", 3) cuts inside ë and panics with end byte index 3 is not a char boundary; it is inside 'ë' (bytes 2..4 of string). One bug, two faces, and which one you get is luck about where the multi-byte character happens to fall. The fix works in characters on both lines — text.chars().count() <= limit and text.chars().take(limit).collect::<String>() — and note that the corrected version never indexes the string at all. That is the general shape of the cure: any byte index you did not get from .char_indices() is a guess.
Common String Operations
The String and &str types come with a rich set of methods for searching, splitting, trimming, and transforming text.
fn main() {
let sentence = " Rust makes text handling safe and expressive. ";
// Trim whitespace
let trimmed = sentence.trim();
println!("{:?}", trimmed);
// Check contents
println!("starts with 'Rust': {}", trimmed.starts_with("Rust"));
println!("contains 'safe': {}", trimmed.contains("safe"));
// Split and collect
let words: Vec<&str> = trimmed.split_whitespace().collect();
println!("word count: {}", words.len());
println!("first word: {}", words[0]);
// Replace
let replaced = trimmed.replace("safe", "powerful");
println!("{}", replaced);
// To uppercase / lowercase
println!("{}", "Hello".to_uppercase());
println!("{}", "WORLD".to_lowercase());
// Parse a number from a string
let n: i32 = "42".parse().expect("not a number");
println!("parsed: {}", n);
}
Building Strings Efficiently
When constructing a string from many pieces, avoid repeated + concatenation — each + may allocate. Use format! for small cases, or push into a String buffer for larger ones.
fn main() {
// Inefficient: each + may reallocate
// let result = "a".to_string() + "b" + "c" + "d";
// Better: format! for a few pieces
let (first, last) = ("Jane", "Doe");
let full_name = format!("{} {}", first, last);
println!("{}", full_name);
// For pieces that arrive one at a time: push into a buffer
let items = vec!["apple", "banana", "cherry"];
let mut result = String::new();
for (i, item) in items.iter().enumerate() {
if i > 0 {
result.push_str(", ");
}
result.push_str(item);
}
println!("{}", result); // apple, banana, cherry
// Best when you already have the pieces in a collection: join()
let joined = items.join(", ");
println!("{}", joined); // apple, banana, cherry
}
Notice which type each of those functions takes and which it returns: .trim() and .split_whitespace() hand back &str values that point into the original text, while .replace() and .to_uppercase() must allocate a fresh String because the result is not a substring of anything. That split is not a string-specific rule — it is the rule you already know, wearing string clothes. Answer this from memory before reading on:
Recall
Without scrolling up: Ownership & Borrowing taught you the difference between a value you own and a reference that borrows one, and that a borrow may not outlive its owner. Apply it to the two string types. Which statement is right about String and &str?
String is to &str exactly as Vec<T> is to &[T]: an owner of a heap buffer, and a view into bytes owned by something else. Everything the two string types do differently follows from that. You cannot push onto a &str because it does not own the buffer it would have to grow. &String coerces to &str for free because a borrow of an owner is a view of what it owns. And any function that builds text must return a String, because there is no pre-existing buffer for a slice to point into — which is why .trim() gives you a &str but .replace() gives you a String.
Try It Yourself
Reading about the byte/character split is not the same as staying on the right side of it under pressure. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out, each one a small piece of a text workbench, and every check is run twice — once on pure ASCII and once on text with a multi-byte character. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
The ASCII half of every check will pass for a byte-based solution too. The accented half is the whole exercise: it is there to fail exactly the shortcut the debug block above diagnosed. If your ellipsize returns "Zoë " instead of "Zoë W...", you have written the bug rather than the fix. Everything you need is in this lesson — .chars(), .count(), .take(), .split_whitespace() and collect().
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each behaves. Run it as-is to see which check fails first, decide what that function needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Every function is checked on ASCII AND on accented text; a byte-based answer will pass the first and fail the second.
// A small text workbench. Every function must be UTF-8 safe: it counts and
// cuts by CHARACTER, never by byte, so a name with an accent behaves like any
// other.
// TODO 1: return how many CHARACTERS are in text.
// Careful: .len() gives BYTES, and for "héllo" that is 6, not 5.
fn char_len(text: &str) -> usize {
let _ = text;
0
}
// TODO 2: return the uppercased first letter of every whitespace-separated
// word, joined with dots.
// initials("ada lovelace") -> "A.L"
fn initials(full_name: &str) -> String {
let _ = full_name;
String::new()
}
// TODO 3: if text is at most limit CHARACTERS, return it unchanged;
// otherwise return its first limit characters followed by "...".
// Do NOT slice with &text[..limit] — that is a BYTE index and will either
// cut in the wrong place or panic on a multi-byte character.
fn ellipsize(text: &str, limit: usize) -> String {
let _ = (text, limit);
String::new()
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
assert_eq!(char_len("hello"), 5, "char_len counts characters for ASCII");
assert_eq!(
char_len("héllo"),
5,
"char_len must count CHARACTERS, not bytes — the string héllo is 6 bytes but 5 chars"
);
assert_eq!(initials("ada lovelace"), "A.L", "initials should uppercase each first letter");
assert_eq!(
initials("Émilie du Châtelet"),
"É.D.C",
"initials must work when the first letter is multi-byte"
);
assert_eq!(ellipsize("hello", 10), "hello", "short text is returned unchanged");
assert_eq!(ellipsize("hello world", 5), "hello...", "long ASCII text is cut at 5 chars");
assert_eq!(
ellipsize("Zoë Washburne", 5),
"Zoë W...",
"ellipsize must cut at 5 CHARACTERS; cutting at 5 bytes would give the 4-char Zoë"
);
println!("All checks passed.");
println!("char_len: {} {}", char_len("hello"), char_len("héllo"));
println!("initials: {}", initials("Émilie du Châtelet"));
println!("ellipsize: {}", ellipsize("Zoë Washburne", 5));
}Expected output: All checks passed.
char_len: 5 5
initials: É.D.C
ellipsize: Zoë W...
Once it passes, try two variations and predict each before running:
- Write the bug on purpose. Change
char_lentotext.len()and predict which of the two checks fails before running. The ASCII one passes and the accented one fails withleft: 6, right: 5— a precise demonstration of why this class of bug reaches production. Every test you wrote in English passed. - Cut at a different limit. Call
ellipsize("Zoë Washburne", 3)from aprintln!and predict what your correct implementation returns. It gives"Zoë..."calmly. Now try the same limit with the broken byte version from the debug block: it panics, because byte 3 lands insideë— while at limit 5 that same broken version returned a quietly wrong answer instead. Working in characters removes the panic and the silent wrong answer together, which is why there is never a reason to reach for a byte index you did not get from.char_indices().
Key Takeaways
Stringis owned and heap-allocated;&stris a borrowed slice pointing to existing UTF-8 data- Rust strings are always valid UTF-8 — the compiler and runtime enforce this
- You cannot index a string with a plain integer because characters may be multi-byte; use
.chars()instead - Use
format!()or aStringpush buffer to build strings efficiently; avoid chaining+for many pieces - Common methods like
.trim(),.split_whitespace(),.contains(),.replace(), and.parse()cover most real-world needs &Stringcoerces automatically to&str— prefer&strin function parameters for maximum flexibility
Pro Tip: In new code, a function that only reads string data should take
&strrather than&String. It accepts string literals and&Stringalike via deref coercion, so callers never have to allocate aStringjust to call you — and it costs one indirection less, since a&Stringis a reference to a reference to the bytes. Clippy'sptr_arglint flags&Stringparameters for exactly this reason. If you noticed that Ownership & Borrowing and Borrowing in Depth wrote&Stringthroughout, that was deliberate: those exercises were teaching what a borrow is, and&Stringnext toStringis the sharpest way to show it — same type, one ampersand, and the caller either keeps ownership or loses it. Swapping in&strthere would have changed the parameter's type as well as its ownership, blurring the one contrast being drawn, which is why the build exercise pins its signatures. Here the goal is different — flexibility for callers — so&stris the parameter type to reach for.
Next Steps
With strings mastered, you're ready to learn how to organize your code into modules and use external crates — Rust's package ecosystem.
Next lesson
Modules and Crates
Learn how to organize Rust code with modules and manage dependencies using crates and Cargo
25 min