How to slice a string safely
Short answer: &text[start..end] cuts a &str at byte offsets, and both offsets have to land on character boundaries. Cut through the middle of a multi-byte character and the program panics. text.get(start..end) returns Option<&str> instead — None where the slice would have panicked.
The previous article in this chapter used char_indices() to find the byte offset of every character; cutting at those offsets is what this page is about. From a fresh project — cargo new slice-demo, then replace src/main.rs with each block below and run it with cargo run. The project name only shows up in cargo’s own output; nothing else here depends on it.
The slice syntax
Section titled “The slice syntax”fn main() { let text = "Ada Lovelace"; let first = &text[..3]; println!("{first}"); let name = &text[4..]; println!("{name}"); let middle = &text[4..8]; println!("{middle}");}AdaLovelaceLove&text[..3] is the first three bytes, &text[4..] everything from byte 4 to the end, and &text[4..8] a half-open range: the byte at end is not in the slice. The result is a &str that borrows the original — nothing is copied.
The offsets are byte offsets, and that’s the trap. “Ada Lovelace” is all ASCII, so here they double as character positions. The habit built on ASCII text breaks the first time the text isn’t.
The panic at the wrong byte
Section titled “The panic at the wrong byte”fn main() { let text = "café"; let slice = &text[..4]; println!("{slice}");}thread 'main' panicked at src/main.rs:3:22:end byte index 4 is not a char boundary; it is inside 'é' (bytes 3..5 of string)note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceé is two bytes, so in “café” it occupies bytes 3 and 4. Index 4 lands inside it, and a slice has to hold whole characters — the result is still UTF-8 text. The check happens at run time, and the message names the character and its byte range, which is usually enough to spot the mistake. The start end is the same check: &text[4..] panics with start byte index 4 is not a char boundary.
This is also why &text[..1] is not “the first character” — unless the text is ASCII, the first character is more than one byte long.
get() returns None instead
Section titled “get() returns None instead”fn main() { let text = "café"; println!("{:?}", text.get(..3)); println!("{:?}", text.get(..4)); println!("{:?}", text.get(..2));}Some("caf")NoneSome("ca")get takes the same ranges as the indexing syntax and hands back Option<&str>: Some(slice) when both ends sit on boundaries, None when they don’t. Out-of-range and reversed ranges come back None too. When the offsets come from somewhere else — a file, a user, a search result — get turns a crash into a value you can branch on.
Offsets you can trust
Section titled “Offsets you can trust”str::is_char_boundary(i) answers “can I cut here?” for a single byte index:
fn main() { let text = "café"; for i in 0..=text.len() { if text.is_char_boundary(i) { println!("{i}: [{}]", &text[..i]); } }}0: []1: [c]2: [ca]3: [caf]5: [café]Byte 4 is missing — it’s inside é. Index 0 and len() are always boundaries: the empty slice and the whole string. When the offsets come from char_indices(), as in the previous article, they’re boundaries by construction, so slicing at them can’t panic. That’s the safe way to cut by character count:
fn first_chars(text: &str, n: usize) -> &str { match text.char_indices().nth(n) { Some((i, _)) => &text[..i], None => text, }}
fn main() { println!("[{}]", first_chars("éclair", 3)); println!("[{}]", first_chars("éclair", 20));}[écl][éclair]char_indices().nth(n) is the byte offset where the n-th character starts, so &text[..i] is exactly the first n characters. When the text is shorter, nth returns None and the whole string comes back.
Related
Section titled “Related”- How to iterate over the characters of a string — where the byte offsets come from
- How to split a string — cutting at a separator instead of an offset
- What is the difference between String and &str? — the UTF-8 buffer underneath
- Working with text — the rest of the chapter