Why you can't index a String in Rust (UTF-8, chars and bytes)
Why s[0] doesn't compile for a Rust String, how UTF-8 works, and how to get characters with chars(), char_indices() and safe slicing.
Coming from Python or JavaScript, the first surprise is that this doesn't compile:
fn main() {
let s = String::from("hello");
let c = s[0];
println!("{c}");
}
error[E0277]: the type `str` cannot be indexed by `{integer}`
Why: strings are UTF-8 bytes
A Rust String (and &str) stores text as UTF-8, where one character takes between 1 and 4 bytes. ASCII letters take 1 byte, é takes 2, € takes 3, and an emoji like 🦀 takes 4.
fn main() {
for word in ["crab", "café", "🦀"] {
println!("{word}: {} bytes, {} chars", word.len(), word.chars().count());
}
}
crab: 4 bytes, 4 chars
café: 5 bytes, 4 chars
🦀: 4 bytes, 1 chars
So s[0] would be ambiguous (the first byte? the first character?), and finding the n-th character requires scanning from the start, which isn't the O(1) that indexing promises. Rust makes you say what you mean.
What to use instead
| You want | Write |
|---|---|
| the first character | s.chars().next() (an Option<char>) |
| the n-th character | s.chars().nth(n) (O(n)) |
| number of characters | s.chars().count() |
| number of bytes | s.len() |
| characters with byte positions | s.char_indices() |
| the raw bytes | s.bytes() or s.as_bytes() |
| a substring by byte range | &s[start..end], at character boundaries |
| reverse | s.chars().rev().collect::<String>() |
If you do many position-based operations, convert once: let chars: Vec<char> = s.chars().collect(); and then index chars[i] freely.
Slicing is by bytes, and must hit boundaries
&s[0..3] takes bytes 0 to 2. If the range ends in the middle of a character, the program panics:
fn main() {
let s = String::from("héllo");
println!("{}", &s[0..1]);
println!("{}", &s[0..2]);
}
h
The panic message is end byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3 of string). Get safe positions from find, char_indices, or s.is_char_boundary(i), or use s.get(0..2), which returns None instead of panicking.
ASCII-only data
For data you know is ASCII (hex strings, simple identifiers, DNA sequences), working with bytes is fast and simple: s.as_bytes()[0] is a u8, and b'a' is a byte literal. Methods like is_ascii_digit, to_ascii_uppercase and eq_ignore_ascii_case exist for both char and u8.
Characters vs what users see
A char is a Unicode scalar value, not always what a reader would call one character. "é" can be written as one char or as e plus a combining accent (two chars), and many emoji are several chars joined together. For user-facing text operations such as truncating names for display, use the unicode-segmentation crate's grapheme clusters.
Example
fn initials(name: &str) -> String {
name.split_whitespace().filter_map(|w| w.chars().next()).collect()
}
fn truncate_chars(s: &str, max: usize) -> &str {
match s.char_indices().nth(max) {
Some((byte_pos, _)) => &s[..byte_pos],
None => s,
}
}
fn main() {
let city = String::from("Zürich");
println!("{city}: {} bytes, {} chars", city.len(), city.chars().count());
println!("first char {:?}, third {:?}", city.chars().next(), city.chars().nth(2));
for (pos, c) in city.char_indices().take(3) {
println!("byte {pos}: {c}");
}
println!("{:?} {:?}", city.get(0..2), city.get(0..3));
println!("reversed {}", city.chars().rev().collect::<String>());
println!("initials {}", initials("Grace Brewster Hopper"));
println!("truncated {}", truncate_chars("crème brûlée", 7));
let code = "a7f3";
println!("first byte {} ({})", code.as_bytes()[0], code.as_bytes()[0] as char);
}
Output:
Zürich: 7 bytes, 6 chars
first char Some('Z'), third Some('r')
byte 0: Z
byte 1: ü
byte 3: r
None Some("Zü")
reversed hcirüZ
initials GBH
truncated crème b
first byte 97 (a)