String Is Owned, str Is Borrowed: the Distinction That Shapes Every Signature
A String is a growable heap buffer of UTF-8 bytes — conceptually Vec<u8> with methods that assume text — while &str is a borrowed view into string data somewhere else: a literal, a slice of a String, a field of a struct. The same three-word story as Vec and slices, with UTF-8 rules layered on:
String = ptr + len + cap (owns heap memory, can grow/reallocate)
&str = ptr + len (fat pointer: where the bytes are and how many)
'static str = ptr + len, bytes compiled into the binary
That ownership split is why APIs are written the way they are. A library function almost never takes String, because it forces every caller to allocate one:
// Hostile to callers: you must build a String even from a literal
fn find_user(name: String) -> Option<User> { ... }
// Idiomatic: accepts "hello", a &String, a slice of a buffer
fn find_user(name: &str) -> Option<User> { ... }
Inside, use .to_owned() or .to_string() only when you must keep the data beyond the call — both do the same thing (&str → String via ToOwned/ToString), and to_string is the idiomatic choice for text. The reverse direction is a free borrow: s.as_str() turns &String into &str, and mostly you do not even need it because String derefs to str, so every str method is available on a String directly:
let mut greeting = String::from("hello"); // also: "hello".to_string(), "hello".to_owned()
greeting.push_str(", world"); // append &str; no reallocation until capacity is exceeded
greeting.push('!'); // append one char — cannot split a multibyte sequence
let shown: &str = greeting.as_str(); // borrow for display, or just pass `&greeting`
push_str takes &str and copies bytes in; push takes char and encodes its 1–4 bytes correctly — neither can produce invalid UTF-8, which is an invariant String maintains always: you cannot construct one that isn't valid UTF-8 without unsafe. That guarantee is what lets str methods assume valid encoding and what makes String safe to expose anywhere.
The conversion table, since every direction comes up:
| From → To | Call | Cost |
|---|---|---|
&str → String |
.to_string() / .to_owned() / String::from(s) |
allocation + copy |
&String → &str |
s.as_str() or implicit deref |
free |
String → &str in a call |
pass &greeting (auto-deref/coercion) |
free |
String → &str keeping ownership |
greeting.as_str() while greeting lives |
free |
String → Vec<u8> |
.into_bytes() |
moves, no copy |
&[u8] → String |
String::from_utf8(v).map_err(...) |
checks UTF-8, may fail |
Ownership also decides cloning policy: passing String by value moves it (the caller loses it), passing &str lets both keep using the data. When a struct needs to retain text past the borrow, clone explicitly — config.name.to_string() — so the cost is visible in review. This is the exact same discipline as Vec<T> vs &[T] in Vec and Dynamic Arrays; strings differ only in that their contents must survive UTF-8 validation, which is where the panics in the next chapter come from.
Inside a struct, String fields are the default because the struct owns its data; a struct holding &'a str is possible but pins every instance to that lifetime, which rarely pays off outside parsers that borrow from their input. Building a String field from parts is either two pushes or one format! — both are correct, and the write!-into-buffer form from the third chapter is the one to reach for when the parts come from a loop.
Byte Length, Char Boundaries, and the Panic You Get for Free
Here is the line that surprises every newcomer:
let s = String::from("héllo"); // h(1) + é(2) + l(1) + l(1) + o(1) = 6 bytes
println!("{}", s.len()); // 6 — BYTES, not characters
println!("{}", &s[0..2]); // "hé" — 0 and 2 both land on char boundaries
println!("{}", &s[1..2]); // panics: byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3)
Rust strings are UTF-8, where one character occupies 1 to 4 bytes (é = 2, 世 = 3, 🦀 = 4). len() and slicing are defined in bytes, and slicing requires the indices to land exactly on character boundaries. Cross one and the program panics with a message that tells you the offending character and its byte range — a bounds check performed on every slice, which is what keeps str slices memory-safe.
The toolkit for working at the right level:
let s = "héllo 🦀";
s.len() // 11 bytes (6 + 1 space + 4 for the crab)
s.chars().count() // 7 Unicode scalar values
s.char_indices() // (byte_index, char) pairs — (0,'h'), (1,'é'), (3,'l'), ...
s.is_char_boundary(3) // true — safe to slice at 3
s.chars().rev().take(2) // lazy iterator over chars, right to left
char_indices is the workhorse: iterate characters while knowing each one's byte range, which lets you slice around a character without ever constructing an invalid index. is_char_boundary(i) is the predicate behind String::truncate — truncate panics if you hand it a non-boundary index, so clamp with char_indices first:
let mut s = String::from("héllo world");
let cut = s.char_indices().map(|(i, _)| i).find(|&i| i > 6).unwrap_or(s.len());
s.truncate(cut); // safe: every char_indices() value is a boundary
Note the distinction that stops people over-apologizing about Unicode: Rust's char is a scalar value, not what a user thinks of as one visible glyph. Emoji joined with zero-width joiners (👩👩👧👦 = 7 chars), combining accents (é as e + U+0301 = 2 chars), and grapheme clusters need the unicode-segmentation crate (s.graphemes(true)) for user-facing operations like "truncate this display name to 10 characters." Slicing a display string by char count can still cut a cluster in half — correct for code, wrong for people.
Where panics legitimately bite in real programs: parsing a fixed byte window out of a protocol message (&buf[start..end] where start came from the peer), cropping log lines for output, and any len() arithmetic that assumes 1 byte per character. The defensive pattern is always the same two lines — compute the index with char_indices/is_char_boundary, and slice only after it validates. Alternatively drop to bytes: s.as_bytes()[i..j] never panics on boundaries (it panics only on range), at the cost of possibly yielding an invalid UTF-8 slice you must then decode.
The character-level building blocks you will reach for constantly:
'世'.len_utf8() // 3 — how many bytes this char occupies
let mut buf = [0u8; 4];
let s: &str = '世'.encode_utf8(&mut buf); // encode into a stack buffer, no allocation
let text = " 42 apples ";
text.trim() // &str, zero copy
text.trim().parse::<u32>() // Result<u32, _> — the fallible parse you propagate with ?
"Ünïcödé".chars().map(|c| c.to_ascii_lowercase()) // non-ASCII stays untouched by ASCII-only ops
to_ascii_lowercase versus to_lowercase is a real distinction: the former touches only A..Z and is allocation-free on &str, while the latter returns a new String and implements Unicode case mapping (where one char can become several — ß.to_uppercase()is"SS"). For byte-protocol work, is_ascii_*predicates onu8` are the cheapest filter you have.
The trade between levels is always the same: byte operations are fastest and know nothing about characters, char operations are correct for Unicode semantics and allocate when they produce new text, and grapheme operations are correct for humans and cost an extra crate. Pick the level from the question being asked — "find the newline" is bytes, "count letters" is chars, "truncate this display name" is graphemes — and the panic risk disappears because each level only slices where its own invariants hold.
Building, Iterating, and Mutating Text Without Accidental Allocations
Once the ownership and encoding rules are set, the remaining skill is writing string code that reads well and does not allocate more than it must. Iteration has three levels, and mixing them is the usual bug source:
let text = " crab snake whale ";
for c in text.chars() { } // char: correct for Unicode-aware text
for b in text.as_bytes() { } // u8: fast scans, protocol parsing
for word in text.split_whitespace() { } // &str: tokenizing, no allocation
chars() is the default: for c in s.chars().filter(|c| c.is_alphanumeric()) works per scalar value and never splits a sequence. as_bytes() is for byte-level work — searching for \n, checksums, validating a length prefix — and it borrows, so the String stays usable. split_whitespace and split(...) yield &str slices into the original, so tokenizing an entire log line costs zero allocations; only when you keep a token beyond the original's lifetime do you call .to_string() on it.
Mutation is where allocation cost hides. Every format!, +, and to_string() in a loop creates a fresh String; the fix is to reuse one buffer:
// Allocation-heavy: one String per element
let mut joined = String::new();
for id in ids { joined += &id.to_string(); joined.push(','); }
// One allocation total
let mut joined = String::with_capacity(ids.len() * 9);
for (i, id) in ids.iter().enumerate() {
if i > 0 { joined.push(','); }
joined.push_str(id);
}
// Or, for formatting, write into the buffer
use std::fmt::Write;
let mut out = String::with_capacity(64);
write!(out, "{} items, {} errors", ok, fail).unwrap(); // no intermediate String
write! into a String (std::fmt::Write, not io::Write) reuses the buffer — the standard trick for building output in hot loops. Related in-place tools: push_str/push for appends, replace_range for edits (s.replace_range(..5, "new")), insert_str for splices, and drain(..) when you want the text out and the allocation kept.
Search-and-transform operations return new Strings by design: s.replace('a', "e"), s.to_uppercase(), s.trim().to_lowercase(), s.split(',').collect::<Vec<_>>() — all allocate. That is fine outside hot paths, and worth noticing inside one. For derived data you hold on to, to_lowercase is the canonical example of a place where one allocation per value is correct and one per iteration of an inner loop is a regression you find in a flamegraph, not in review.
The zero-copy habit is what separates string code that scales from string code that allocates per token. When parsing, keep borrowing:
fn parse_kv(line: &str) -> Option<(&str, &str)> {
let (k, v) = line.split_once('=')?; // both are slices INTO `line`
Some((k.trim(), v.trim())) // trims are zero-copy too
}
// Allocate only the values you keep:
let map: HashMap<&str, &str> = text.lines().filter_map(parse_kv).collect();
Every trim, split, strip_prefix, and to_lowercase-on-a-&str slice returns &str views into the original buffer — no allocation exists until you call .to_string() on something you retain. That is why idiomatic parsers take &str and hand back &str, and why the lifetime parameters appear on parser signatures: the slices live exactly as long as the input buffer. When a value might be borrowed and might be owned — the case where a fallback string is synthesized only if the input lacks a field — Cow<'a, str> names that choice (Borrowed(&str) or Owned(String)), letting the caller avoid an allocation on the happy path while still returning one type.
The UTF-8 safety net never stops: push_str(&other) rejects invalid input only by type (&str is already valid), while String::from_utf8(bytes) is where malformed data from disk or the network gets caught — usually turned into map_err + context, exactly like any other fallible parse, and propagated with ? as you learned in The question mark Operator.
Quiz
1. `let s = String::from("héllo");` What are `s.len()` and `s.chars().count()`?
2. `&s[1..3]` on `String::from("héllo")` panics. Why, and what is the fix?
3. Which function signature should a reusable library function use?
4. Which loop allocates a new String per iteration?
Flashcards
Question
What are the layouts of `String` and `&str`, and what invariant does `String` guarantee?
Click to reveal answer
Answer
`String` = pointer + length + capacity to a heap UTF-8 buffer it owns (grows like `Vec<u8>`); `&str` = pointer + length borrowed from elsewhere (literal, slice, field). `String` is always valid UTF-8 — it cannot be built from invalid bytes without `unsafe`, and `String::from_utf8` checks at runtime.
Question
Conversions between `&str` and `String` — name two in each direction and their cost.
Click to reveal answer
Answer
`&str → String`: `.to_string()`, `.to_owned()`, or `String::from(s)` — allocate and copy. `String → &str`: `.as_str()` or just pass `&greeting` (Deref coercion) — free borrow, caller keeps ownership. Moving ownership out: `.into_bytes()` for Vec<u8>, or pass the String by value.
Question
Why does `s.len()` mislead, and how do you slice a string safely?
Click to reveal answer
Answer
`len()` (and slicing) counts UTF-8 *bytes*, where a char is 1–4 bytes — so "héllo" is 6. Slicing must land on char boundaries or it panics. Safe patterns: get indices from `char_indices()`, check `is_char_boundary(i)`, count with `chars().count()`, or drop to `as_bytes()` for byte work.
Question
When do `chars()` and `graphemes` disagree, and which do you use for user-facing truncation?
Click to reveal answer
Answer
They disagree for combining marks, ZWJ emoji families, and flags — one visible glyph can be several scalar values. `chars()` is right for code (case, encoding, filters); user-facing "show me 10 characters" needs `unicode-segmentation::graphemes` so clusters are not cut in half.
Question
How do you build a string in a loop without allocating each iteration?
Click to reveal answer
Answer
Allocate once with `with_capacity` and `push_str`/`push`, or `write!` into the buffer via `std::fmt::Write`. Avoid `+= &format!(...)` and `to_string()` per element — each creates a fresh String; `replace_range`/`insert_str` mutate in place when the text already exists.
Revision Notes
Key Takeaways
- 1. `String` owns a growable UTF-8 buffer; `&str` is a borrowed view — the same ownership split as `Vec`/`&[T]`, which is why signatures take `&str` and clone only when retaining
- 2. `String` maintains valid UTF-8 as an invariant, so `push`/`push_str` cannot corrupt text; `from_utf8` is where external bytes are validated
- 3. `len()` and slicing operate on bytes; slicing that crosses a character boundary panics with the byte range of the split character
- 4. `chars()` handles code, `graphemes` handles people — user-facing truncation needs cluster awareness, not scalar values
- 5. String-building loops should allocate once (`with_capacity`, `write!`); `to_uppercase`, `replace`, and `format!` all return fresh `String`s by design
Interview Tips
- • "\"What's the difference between String and &str?\" — ownership plus layout (ptr/len/cap vs ptr/len), then the API rule: take `&str`, clone only if you retain; mention Deref coercion so `&greeting` works"
- • "\"Why does my slice panic?\" — byte index not on a char boundary; walk UTF-8's 1–4 byte encoding, then show `char_indices()` as the fix and `is_char_boundary` as the predicate behind `truncate`"
- • "\"How do you count characters?\" — `chars().count()`, not `len()`; follow up with the grapheme-cluster caveat for emoji and combining marks to show real Unicode experience"
- • "\"You see `format!` inside a hot loop — what do you do?\" — hoist one buffer with `with_capacity` and `push_str`, or `write!` into a String; quantify: one allocation total instead of one per iteration"
Cheat Sheet
String and str
Layout & conversions:
String = ptr + len + cap (owns heap UTF-8 bytes, grows like Vec)
&str = ptr + len (borrowed view; 'static str lives in the binary)
"hi".to_string() // &str -> String (allocates)
s.as_str() / &s // String -> &str (free; Deref<Target = str>)
s.into_bytes() // String -> Vec<u8> (moves)
String::from_utf8(v) // Vec<u8> -> Result<String, FromUtf8Error> (validates)
API shape: parameters take &str; clone (.to_string()) only when the callee must keep the text.
Bytes vs chars:
| Question | Call |
|---|---|
| How many bytes? | s.len() |
| How many chars? | s.chars().count() |
| Safe cut points? | s.char_indices() / s.is_char_boundary(i) |
| Visible glyphs? | unicode_segmentation::graphemes(s, true) |
| Byte-level scan? | s.as_bytes() |
Slicing: &s[a..b] panics unless both bounds are char boundaries (byte index N is not a char boundary). Build indices from char_indices, never from arithmetic on len().
Mutation:
s.push('!'); s.push_str(" world"); // append, keeps UTF-8 valid
s.replace_range(..5, "new"); s.insert_str(0, "pre: ");
s.truncate(n); // panics if n is not a boundary
// hot loops: with_capacity + push_str, or write!(buf, ...) via fmt::Write