RustStrings In-Depth

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 char in Rust is a Unicode scalar value: any Unicode code point except surrogate code points. It is always 4 bytes in memory.
  • .len() on a String or &str returns the byte count, not the character count.
  • Some Unicode characters require more than one byte in UTF-8.

RUST
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
Warning
Never call .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:

  1. Bytes.bytes() yields raw u8 values. Use when you need the actual encoded bytes (e.g. network I/O, file I/O).

  2. Unicode scalar values.chars() yields char values. Use for most text processing where you care about code points.

  3. Grapheme clusters — requires the unicode-segmentation crate. A grapheme cluster is what humans perceive as a single character (e.g. a base character + combining accent = one cluster, but two chars).

RUST
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.

RUST
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
Tip
Avoid building strings with repeated + 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

RUST
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

RUST
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

RUST
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
Note
.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

RUST
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:

RUST
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
Tip
Use 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 the unicode-segmentation crate.

  • 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. Use push_str on 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. String is an owned, heap-allocated value. &str is a borrowed reference to string data (in a String, in a literal, anywhere). Function parameters should usually accept &str for 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

Success
Rust's string types might feel strict at first, but the strictness is what makes them reliable. UTF-8 validity is guaranteed, indexing is explicit about bytes vs. characters, and the rich iterator API makes most string operations both expressive and efficient.