TL;DR
Understand Rust's two string types, text manipulation, UTF-8 encoding, and common string operations
Key concepts
- Rust strings
- Rust string types
- Rust String vs str
- Rust UTF-8
- Rust text manipulation
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.
What You'll Learn
By the end of this lesson you will build a small text workbench — count a label's length, reduce a name to its initials, cut a string to fit a narrow column — and every one of those functions is checked twice, once on plain ASCII and once on text with an accent in it. That second check is the lesson. The byte-based shortcut passes the first and fails the second, and it fails in two different ways depending on where the cut lands: sometimes a loud panic, sometimes a quietly wrong answer that nothing in the toolchain objects to.
The capstone's taskwork CLI leans on this directly: it splits each task line on a | with split_once, trims both halves, and escapes characters one at a time when it renders JSON — none of which is safe if you reach for a byte index. You arrive here able to say what a borrow is and why a view may not outlive its owner (Ownership, and Borrowing in Depth); what is new is that the same owned-versus-borrowed pair now has two names, String and &str, and a second axis on top of it: bytes versus characters.
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.
Reading a char-boundary panic
Here is what that panic actually says. On the toolchain behind this page's Run button (rustc 1.97.1), slicing "Zoë Washburne" with &text[..3] produces exactly this:
thread 'main' (576) panicked at /tmp/main.rs:5:31:
end byte index 3 is not a char boundary; it is inside 'ë' (bytes 2..4 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Read it in three moves, and notice first what is missing: this is a panic, not a compiler diagnostic, so there is no --> pointing into your source, no ^^^ underlining an expression, and no help: offering a fix. You get a location, a sentence, and nothing about why.
end byte index 3names the number you supplied and the unit it was interpreted in.endtells you which side of the range was at fault — the same mistake on the other side of the range readsstart byte index 3 is not a char boundary, so you never have to guess which half ofa..bwas wrong. If that number came from.len(), from arithmetic, or from a constant, it is a byte count and you were probably thinking in characters.it is inside 'ë' (bytes 2..4 of string)is the part that solves the bug, and it is doing something unusually generous: it names the character your index landed in the middle of, and the exact byte range that character occupies.2..4meansëstarts at byte 2 and ends at byte 4, so the only legal boundaries near your 3 are 2 and 4. A panic message that hands you the valid answers is rare; take it.thread 'main' (576) panicked at /tmp/main.rs:5:31is where — file, line, column. The parenthesised number is an operating-system thread id and varies from run to run. The location is the slice, which is usually not where the bad index was computed, and that gap is the thing to be alert to: the panic fires at the point of use, while the mistake lives wherever the number came from.
The note: line is the fourth step and the one you take at a terminal: RUST_BACKTRACE=1 adds the chain of calls that led here, which is what closes the gap in point 3 when the slice sits inside a helper that many callers reach. It does not run on this page, since the Run button sets no environment variables.
The routine has a limit worth stating plainly, because it is the whole argument of the next exercise: this panic only fires when you are unlucky. Move the same wrong index one byte over and it lands on a boundary by accident, no panic is raised, and you get a real string that is simply the wrong one. There is no message to read in that case at all.
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.
What len Means Elsewhere
If Rust's answer of 6 for a five-letter word felt arbitrary, it is worth seeing that no language gives you the answer you wanted — they each give you a number in their unit, and the units differ:
| string | Rust .len() | Go len() | JavaScript .length | Rust .chars().count() |
|---|---|---|---|---|
"héllo" | 6 | 6 | 5 | 5 |
"🦀" | 4 | 4 | 2 | 1 |
Transfer
Rust and Go both report 6 for the five-letter word héllo, and both report 4 for a single crab emoji. JavaScript reports 5 and 2 for the same two strings. None of those four numbers is a character count except the last column. What is the invariant that holds across all three languages?
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 returns &str: the result is a SUBSTRING of sentence, so it can be a
// view into bytes that already exist rather than a fresh allocation.
let trimmed = sentence.trim();
println!("{:?}", trimmed);
// Check contents
println!("starts with 'Rust': {}", trimmed.starts_with("Rust"));
println!("contains 'safe': {}", trimmed.contains("safe"));
// Also &str, and for the same reason: every word is a slice of trimmed.
// Vec<&str> therefore holds views, not copies - and none of them may
// outlive sentence.
let words: Vec<&str> = trimmed.split_whitespace().collect();
println!("word count: {}", words.len());
println!("first word: {}", words[0]);
// replace returns String, because "powerful" is not anywhere in the
// original - there is no existing buffer for a slice to point into, so new
// text has to be built and owned.
let replaced = trimmed.replace("safe", "powerful");
println!("{}", replaced);
// Same rule again. Uppercasing may change the length in bytes, so the
// result is new text and must be an owned String.
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.
Three ways to assemble the same list, worked in order. The comments say why each stage was chosen over the one above it rather than what the lines do — that reasoning is the transferable part:
fn main() {
let items = vec!["apple", "banana", "cherry"];
// Stage 1: + on String. Each + takes the left side by VALUE, so it must be
// rebound every time, and every step may reallocate to fit the result.
let mut a = String::new();
for item in &items {
a = a + item + ", ";
}
println!("1: {:?}", a);
// Stage 2: push_str into one buffer. Chosen over + because the buffer is
// reused rather than rebound - and the separator now has to be placed by
// hand, which is where the trailing ", " above came from.
let mut b = String::new();
for (i, item) in items.iter().enumerate() {
if i > 0 {
b.push_str(", ");
}
b.push_str(item);
}
println!("2: {:?}", b);
// Stage 3: join. Chosen over stage 2 because the pieces are ALREADY in a
// collection - so the separator logic is not yours to get wrong.
let c = items.join(", ");
println!("3: {:?}", c);
}
Stage 1 prints "apple, banana, cherry, " — with a trailing separator — while stages 2 and 3 print "apple, banana, cherry". That difference is not incidental: placing a separator between n items means writing it n − 1 times, and every hand-rolled loop has to encode that off-by-one somewhere. Stage 2 encodes it in the i > 0 guard; stage 3 does not encode it at all, because join already knows. When the pieces are already in a collection, letting join place the separators removes the only part of this that has a wrong answer.
For a fixed handful of pieces rather than a collection, format! is the shape to reach for — one allocation, and the layout is visible in the template rather than spread over a loop:
fn main() {
let (first, last) = ("Jane", "Doe");
let full_name = format!("{} {}", first, last);
println!("{}", full_name);
}
Now extend the fade yourself. Write a fourth stage that joins the items with ", " but puts " and " before the last one, so three items read apple, banana and cherry. Decide first which of the three shapes above you are extending, and why — join cannot express it, so the answer is one of the other two, and the choice tells you what join was actually buying you.
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.
Cutting safely, in the only order that compiles
There is one situation where a byte index is not a guess: when you got it from .char_indices(), which yields the byte offset at which each character starts. The five lines below build a label, take a safe cut from it that way, print the cut, and then empty the buffer. Two things constrain their order and neither of them is name resolution. A byte offset describes the string as it was when the offset was computed, so appending changes what the same number means. And head is a borrow of label, so it pins label for as long as it is still going to be read.
Here is the frame the five lines drop into, so you are not ordering statements against a buffer you have to infer:
fn main() {
let mut label = String::from("Zo");
// the five shuffled lines go here
println!("cleared to {:?}", label);
}
Arrange the code
These lines build the label Zoe Washburne with an accented e, cut it to its first four characters using an offset char_indices produced, print the cut, and then clear the buffer. The pieces are shuffled. Put them in the order that runs, then answer why this order and not another — and be specific about two pairs in particular: which pair, if swapped, still compiles and still prints something, and which pair the borrow checker refuses outright.
label.clear();println!("head is {}", head);label.push_str("ë Washburne");let head = &label[..cut_at];let cut_at = label.char_indices().nth(4).map(|(i, _)| i).unwrap_or(label.len());
Notice the shape of the safe version: the byte index still exists, and the slice is still a byte slice. What changed is where the number came from. That is the whole rule — a byte index is safe exactly when the string itself produced it, and only until the string changes. The block above makes that second half concrete: the borrow checker will stop you from mutating a string someone is still holding a slice of, but it has nothing to say about an offset you computed and then invalidated by appending. One of those two mistakes is a compile error and the other is a wrong answer.
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.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.