Strings In-Depth
Rust's string handling rewards a little investment up front. Once you understand the
UTF-8 guarantee, the difference between str and String, and why indexing by
integer is forbidden, strings become straightforward to work with. This page covers
the full set of string operations you will reach for in everyday Rust code.
UTF-8 Fundamentals
Every Rust String is guaranteed to be valid UTF-8. This guarantee is enforced at
the type-system level — you cannot construct a String containing invalid UTF-8
without going through unsafe code.
Key facts:
- A
charin Rust is a Unicode scalar value: any Unicode code point except surrogate code points. It is always 4 bytes in memory. .len()on aStringor&strreturns the byte count, not the character count.- Some Unicode characters require more than one byte in UTF-8.
fn main() {
let s = "hello";
println!("'hello' bytes: {}", s.len()); // 5
println!("'hello' chars: {}", s.chars().count()); // 5
let s2 = "héllo"; // é is U+00E9, encoded as 2 bytes in UTF-8
println!("'héllo' bytes: {}", s2.len()); // 6
println!("'héllo' chars: {}", s2.chars().count()); // 5
let s3 = "日本語"; // each kanji is 3 bytes
println!("'日本語' bytes: {}", s3.len()); // 9
println!("'日本語' chars: {}", s3.chars().count()); // 3
}'hello' bytes: 5 'hello' chars: 5 'héllo' bytes: 6 'héllo' chars: 5 '日本語' bytes: 9 '日本語' chars: 3
.len() and assume you get the number of visible characters. Use .chars().count() for Unicode scalar values. For user-visible characters (grapheme clusters) you need theunicode-segmentation crate.Three Views of a String
Rust lets you look at string data through three different lenses depending on what you need:
Bytes —
.bytes()yields rawu8values. Use when you need the actual encoded bytes (e.g. network I/O, file I/O).Unicode scalar values —
.chars()yieldscharvalues. Use for most text processing where you care about code points.Grapheme clusters — requires the
unicode-segmentationcrate. A grapheme cluster is what humans perceive as a single character (e.g. a base character + combining accent = one cluster, but twochars).
fn main() {
let s = "héllo";
// Bytes — raw UTF-8 encoded bytes
print!("bytes: ");
for b in s.bytes() { print!("{} ", b); }
println!();
// Chars — Unicode scalar values
print!("chars: ");
for c in s.chars() { print!("{:?} ", c); }
println!();
// Count each way
println!("byte count: {}", s.len());
println!("char count: {}", s.chars().count());
}bytes: 104 195 169 108 108 111 chars: 'h' 'é' 'l' 'l' 'o' byte count: 6 char count: 5
Building Strings Efficiently
When you know the approximate size of the string you are building, pre-allocate with
String::with_capacity(n) to avoid repeated heap reallocations.
fn main() {
// Pre-allocate for 50 bytes
let mut s = String::with_capacity(50);
s.push_str("Hello");
s.push(',');
s.push(' ');
s.push_str("world!");
println!("{}", s);
println!("len: {}, capacity: {}", s.len(), s.capacity());
// Collect an iterator of &str into a String
let words = vec!["Rust", "is", "fast"];
let sentence: String = words.join(" ");
println!("{}", sentence);
// Collect chars after filtering
let only_letters: String = "h3ll0 w0rld"
.chars()
.filter(|c| c.is_alphabetic())
.collect();
println!("{}", only_letters);
}Hello, world! len: 13, capacity: 50 Rust is fast hllwrld
+ concatenation in a loop — each + moves the left-hand String and allocates a new one. Use push_str / push on a pre-allocatedString, or collect from an iterator instead.Splitting Strings
fn main() {
let csv = "one,two,three,four";
// split by a char delimiter
let parts: Vec<&str> = csv.split(',').collect();
println!("{:?}", parts);
// split_whitespace — handles multiple spaces, tabs, newlines
let sentence = " hello world ";
let words: Vec<&str> = sentence.split_whitespace().collect();
println!("{:?}", words);
// lines — split on
or
let multiline = "line one
line two
line three";
for line in multiline.lines() {
println!(" > {}", line);
}
// splitn — split into at most n pieces
let path = "usr/local/bin/cargo";
let parts2: Vec<&str> = path.splitn(3, '/').collect();
println!("{:?}", parts2);
}["one", "two", "three", "four"] ["hello", "world"] > line one > line two > line three ["usr", "local", "bin/cargo"]
Joining Strings
fn main() {
// Join a slice of &str
let fruits = ["apple", "banana", "cherry"];
println!("{}", fruits.join(", "));
println!("{}", fruits.join(" | "));
// Join after mapping
let numbers = [1, 2, 3, 4, 5];
let s: String = numbers.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(" + ");
println!("{} = 15", s);
}apple, banana, cherry apple | banana | cherry 1 + 2 + 3 + 4 + 5 = 15
Searching in Strings
fn main() {
let text = "the quick brown fox jumps over the lazy dog";
// contains — boolean check
println!("contains 'fox': {}", text.contains("fox"));
// find — first byte index of a pattern (returns Option<usize>)
match text.find("fox") {
Some(i) => println!("'fox' starts at byte {}", i),
None => println!("not found"),
}
// rfind — last occurrence
println!("last 'the' at byte: {:?}", text.rfind("the"));
// matches + count
let count = text.matches('o').count();
println!("letter 'o' appears {} times", count);
// starts_with / ends_with
println!("starts with 'the': {}", text.starts_with("the"));
println!("ends with 'dog': {}", text.ends_with("dog"));
}contains 'fox': true 'fox' starts at byte 16 last 'the' at byte: Some(31) letter 'o' appears 4 times starts with 'the': true ends with 'dog': true
.find() returns a byte index, not a character index. Using that index to slice a string is safe only if it lands on a character boundary — slicing at a non-boundary byte panics at runtime.Replacing and Modifying
fn main() {
let s = "I love Rust! Rust is great!";
// replace — replaces all occurrences
println!("{}", s.replace("Rust", "Python"));
// replacen — replaces at most n occurrences
println!("{}", s.replacen("Rust", "Go", 1));
// trim — remove leading and trailing whitespace
let padded = " hello ";
println!("'{}'", padded.trim());
println!("'{}'", padded.trim_start());
println!("'{}'", padded.trim_end());
// to_uppercase / to_lowercase
let mixed = "Hello World";
println!("{}", mixed.to_uppercase());
println!("{}", mixed.to_lowercase());
// eq_ignore_ascii_case — case-insensitive ASCII comparison
println!("case-insensitive eq: {}", "Rust".eq_ignore_ascii_case("rust"));
}I love Python! Python is great! I love Go! Rust is great! 'hello' 'hello ' ' hello' HELLO WORLD hello world case-insensitive eq: true
Converting from Byte Vectors
Sometimes you have raw bytes and need to convert them to a String. Rust gives you
two options depending on how you want to handle invalid UTF-8:
fn main() {
// Valid UTF-8 bytes
let valid_bytes = vec![104u8, 101, 108, 108, 111]; // "hello"
match String::from_utf8(valid_bytes) {
Ok(s) => println!("valid: {}", s),
Err(e) => println!("error: {}", e),
}
// Invalid UTF-8 bytes — from_utf8 returns Err
let invalid_bytes = vec![104u8, 255, 108, 108, 111];
match String::from_utf8(invalid_bytes.clone()) {
Ok(s) => println!("valid: {}", s),
Err(e) => println!("from_utf8 error: {}", e),
}
// from_utf8_lossy — replaces invalid bytes with the replacement character U+FFFD
let lossy = String::from_utf8_lossy(&invalid_bytes);
println!("lossy: {}", lossy); // h�llo
}valid: hello from_utf8 error: invalid utf-8 sequence of 1 bytes from index 1 lossy: h�llo
String::from_utf8_lossy when reading external data (files, network) that might contain invalid UTF-8. Use String::from_utf8when invalid UTF-8 is a programming error and you want to surface it explicitly.Common String Pitfalls
Calling .len() thinking it returns characters. It returns bytes. Use
.chars().count()for character count. For user-visible characters (grapheme clusters), use theunicode-segmentationcrate.Slicing at a non-character boundary.
&s[0..2]panics if the byte at index 2 is in the middle of a multi-byte character. Always ensure slice indices land on character boundaries. Use.char_indices()to find safe boundaries.Repeated + concatenation in a loop. Each
+moves the left-hand String and allocates a new buffer. Usepush_stron a pre-allocated String or build with.collect::<String>()instead.Indexing a String with s[i]. Rust forbids this because it would be O(n) to validate and could land in the middle of a multi-byte character. Use
.chars().nth(i)or iterate with.char_indices().Forgetting that str and String are different types.
Stringis an owned, heap-allocated value.&stris a borrowed reference to string data (in a String, in a literal, anywhere). Function parameters should usually accept&strfor maximum flexibility.
Quick Reference
Operation | Method | Returns |
|---|---|---|
Byte length | .len() | usize |
Character count | .chars().count() | usize |
Iterate chars | .chars() | Chars iterator |
Iterate bytes | .bytes() | Bytes iterator |
Check prefix | .starts_with(pat) | bool |
Check suffix | .ends_with(pat) | bool |
Find first occurrence | .find(pat) | Option<usize> |
Find last occurrence | .rfind(pat) | Option<usize> |
Replace all | .replace(old, new) | String |
Replace n times | .replacen(old, new, n) | String |
Split | .split(pat) | Split iterator |
Trim whitespace | .trim() | &str |
To uppercase | .to_uppercase() | String |
To lowercase | .to_lowercase() | String |
Case-insensitive eq | .eq_ignore_ascii_case(s) | bool |