Skip to content
AgileLogics
7 min read

Strings: String vs &str

Prerequisites: Slices

Rust has two main string types, and knowing which to use is one of the first practical hurdles:

  • String — an owned, growable, heap-allocated string. Use it when you need to build or modify text, or store it in a struct.
  • &str — a borrowed string slice: a view into text owned by someone else (a String, or a literal in the program binary). Use it for function parameters that only read text.

Both are always valid UTF-8.

Syntax

let mut s = String::new();              // empty String
let s1 = String::from("hello");         // from a literal
let s2 = "hello".to_string();           // same thing
let view: &str = &s1;                   // borrow a String as &str

s.push_str("abc");                      // append a &str
s.push('d');                            // append one char
let joined = format!("{s1}-{s2}");      // combine without moving

+ also joins strings, but it takes ownership of the left side: let c = a + &b; moves a into c. format! doesn't move anything and is usually easier to read.

Why s[0] doesn't compile

UTF-8 uses 1 to 4 bytes per character: "hello" is 5 bytes, but "héllo" is 6, because é takes 2. Indexing by a number would be ambiguous — byte or character? — and couldn't be done in constant time for characters, so Rust doesn't allow it. Instead:

  • .chars() — iterate over characters.
  • .bytes() — iterate over raw bytes.
  • .len() — the length in bytes.
  • .chars().count() — the number of characters.
  • &s[0..1] — slicing by byte range still works, but panics if a boundary falls inside a multi-byte character.

Example

fn main() {
    let mut s = String::from("Hello");
    s.push_str(", world");
    s.push('!');
    println!("{s}");

    let first = String::from("Tic");
    let second = String::from("Tac");
    let joined = first + "-" + &second; // `first` is moved here
    println!("{joined}");
    let game = format!("{joined}-{}", "Toe"); // nothing is moved
    println!("{game}");

    let word = "héllo";
    println!("bytes: {}, chars: {}", word.len(), word.chars().count());
    for c in word.chars() {
        print!("[{c}]");
    }
    println!();

    println!("{}", s.to_uppercase());
    println!("{}", s.contains("world"));
    println!("{}", s.replace("world", "Rust"));

    let padded = "   trim me   ";
    println!("[{}]", padded.trim());
    for part in "one two  three".split_whitespace() {
        println!("- {part}");
    }
}

Expected output

Hello, world!
Tic-Tac
Tic-Tac-Toe
bytes: 6, chars: 5
[h][é][l][l][o]
HELLO, WORLD!
true
Hello, Rust!
[trim me]
- one
- two
- three

Common mistakes

  • Indexing a string with a number:
let s = String::from("hello");
let c = s[0];
error[E0277]: the type `str` cannot be indexed by `{integer}`

The compiler's note suggests .chars().nth() or .bytes().nth().

  • Using a String after + moved it. In let c = a + &b;, a can't be used afterwards. Use format!("{a}{b}") if you need to keep both.
  • Assuming .len() counts characters. It counts bytes; the two only match for plain ASCII text.
  • Taking &String parameters when &str works. fn greet(name: &str) accepts both "Ada" and &my_string.

Practice

Write a function count_vowels(text: &str) -> usize that counts the vowels a, e, i, o, u (in either case) using .chars(). For "Programming in Rust" it should print Vowels: 5.

Show solution
fn count_vowels(text: &str) -> usize {
    let mut count = 0;
    for c in text.chars() {
        if "aeiou".contains(c.to_ascii_lowercase()) {
            count += 1;
        }
    }
    count
}

fn main() {
    println!("Vowels: {}", count_vowels("Programming in Rust"));
}