RustArena

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)

Practice it