Rust Strings Deep Dive: String vs &str and UTF-8
Master Rust strings: difference between String and &str, UTF-8 internal encoding, slicing safety, formatting, and efficient manipulation.
Strings in Rust are notoriously confusing for beginners. Why does Rust have both String and &str? Why can’t you index a string with s[0]? Understanding Rust’s string architecture unlocks how the language guarantees memory safety and international UTF-8 correctness.
Explain Like I’m 10: The Notebook vs The Page Bookmark#
Imagine two ways of working with text:
String(The Expandable Spiral Notebook): A physical notebook you own on your desk (Heap memory). You can write new sentences, erase paragraphs, add extra blank pages (mut), or pass it around.&str(The Transparent Bookmark View): A clear bookmark you lay over a specific line in a printed library book or someone else’s notebook. You can read the text underneath without copying or owning the paper.
graph TD
subgraph HeapMemory ["Heap Allocation"]
StringBuf["String Buffer (UTF-8 Bytes in Heap)"]
end
subgraph StackVariables ["Stack Representation"]
StringObj["String struct<br/>[ ptr | len | capacity ]"] -->|Points to owned data| StringBuf
StrSlice["&str slice<br/>[ ptr | len ]"] -->|Borrows a view of| StringBuf
LiteralStr["static str slice<br/>[ ptr | len ]"] -->|Points to read-only binary text| ReadOnlyData["Binary Text Segment (.rodata)"]
end
String vs &str: The Core Differences#
| Property | String | &str (String Slice) |
|---|---|---|
| Ownership | Owns its heap buffer | Borrows a view of string data |
| Growth | Growable, shrinkable, mutable with mut | Fixed length, read-only view |
| Location | Heap-allocated buffer (stack holds pointer, len, cap) | Stack holds fat pointer (ptr + len) |
| Common Creation | String::new(), String::from("..."), s.to_string() | "hello" (literal), &s[0..4], &s |
Creating and Updating Strings#
fn main() {
// 1. Creating a String
let mut greeting = String::from("Hello");
// 2. Appending characters and slices
greeting.push(' '); // Appends a single char
greeting.push_str("world!"); // Appends a string slice
println!("{greeting}");
// 3. String concatenation with +
let s1 = String::from("Hello, ");
let s2 = String::from("Rust!");
let s3 = s1 + &s2; // s1 is moved here and can no longer be used!
println!("{s3}");
// 4. Clean concatenation with format! macro (no ownership move)
let part1 = "tic";
let part2 = "tac";
let part3 = "toe";
let game = format!("{part1}-{part2}-{part3}");
println!("{game}");
}rust[!NOTE] The
+operator ins1 + &s2calls a method signaturefn add(self, s: &str) -> String. It takes ownership ofs1, appends a copy ofs2, and returns the modified buffer.
Why Rust Prohibits Direct Indexing (s[0])#
In many languages, s[0] returns the first character. In Rust, this is a compile-time error:
let s = String::from("hello");
// let h = s[0]; // ❌ Error: the type `String` cannot be indexed by `{integer}`rustThe UTF-8 Reason#
Rust strings are always valid UTF-8 sequences. Different Unicode characters occupy different numbers of bytes (from 1 to 4 bytes):
graph LR
subgraph English ["English: 'Hello' (1 byte / char)"]
H["'H' [0x48]"] --- E["'e' [0x65]"] --- L1["'l' [0x6C]"] --- L2["'l' [0x6C]"] --- O["'o' [0x6F]"]
end
subgraph Vietnamese ["Vietnamese: 'Chào' (Multi-byte)"]
C["'C' [0x43]"] --- H2["'h' [0x68]"] --- AU["'à' [0xC3, 0xA0] (2 bytes)"] --- O2["'o' [0x6F]"]
end
subgraph Emoji ["Emoji / Japanese: '🦀' (4 bytes)"]
Crab["'🦀' [0xF0, 0x9F, 0xA6, 0x80] (4 bytes)"]
end
If Rust allowed s[0], returning a single byte would often return half a Unicode character, breaking your program silently. Furthermore, indexing would require an O(n) traversal to count multi-byte glyphs, violating Rust’s zero-cost abstraction design.
Iterating Over Strings: Bytes vs Chars#
To inspect strings safely, explicitly choose your unit of iteration:
fn main() {
let word = "Chào 🦀";
// 1. Iterate by Unicode scalar values (chars)
println!("--- Chars ---");
for c in word.chars() {
println!("{c}");
}
// 2. Iterate by raw UTF-8 bytes
println!("--- Bytes ---");
for b in word.bytes() {
print!("{b:02X} ");
}
println!();
}rustString Slicing Safety Warning#
You can slice strings with ranges, but range boundaries must land on valid UTF-8 character boundaries:
fn main() {
let hello = "Здравствуйте";
// Each Cyrillic character takes 2 bytes.
let s = &hello[0..4]; // Takes first 2 characters (4 bytes) -> "Зд"
println!("{s}");
// ❌ Crashing slice: dividing inside a character
// let invalid = &hello[0..1]; // Panics at runtime: byte index 1 is not a char boundary
}rust[!WARNING] Always verify boundary safety or prefer iterator methods like
.chars().take(n)over manual byte range slicing on international text.
Passing Strings to Functions: Idiomatic &str#
When writing functions that only need to read string data, always accept &str rather than &String:
// Idiomatic: accepts both &String and &str thanks to Deref coercion
fn print_length(text: &str) {
println!("Length: {} bytes", text.len());
}
fn main() {
let owned = String::from("Rustacean");
let literal = "Rustacean";
print_length(&owned); // Auto deref &String -> &str
print_length(literal); // Direct &str
}rustSummary#
Stringis an owned, heap-allocated, growable UTF-8 buffer.&stris an immutable borrowed view (slice) into UTF-8 data.- Direct integer indexing is prohibited because UTF-8 characters vary between 1 and 4 bytes.
- Use
.chars()to iterate over Unicode characters and.bytes()for raw byte representation. - Accept
&stras function arguments for maximum flexibility and zero allocation cost.