Skip to editor content
learningrust.orglesson 16 of 26

Strings and Text

Rust's approach to strings surprises many newcomers. Instead of one string type, there are two — and understanding why unlocks a deeper understanding of ownership, memory, and Unicode. Once it clicks, you'll appreciate how much safety Rust gives you for free.

Two String Types

Rust has two primary string types:

  • String — a heap-allocated, growable, owned string
  • &str — a string slice, a borrowed reference to UTF-8 data

Think of &str like a window into existing string data (which may live in compiled binary or on the heap), while String is a fully owned buffer you can modify.

fn main() {
    // &str: a string slice, typically a reference to static data
    let greeting: &str = "Hello, world!";

    // String: heap-allocated, owned, and growable
    let mut name: String = String::from("Alice");
    name.push_str(" Smith");

    println!("{}", greeting);
    println!("{}", name);

    // You can get a &str from a String using & or .as_str()
    let name_slice: &str = &name;
    println!("Slice: {}", name_slice);
}

Creating and Growing Strings

String provides several ways to build and modify text at runtime.

fn main() {
    // Different ways to create a String
    let a = String::new();                        // empty
    let b = String::from("hello");                // from literal
    let c = "world".to_string();                  // via trait method
    let d = format!("{} {}", b, c);               // formatted

    println!("a: {:?}", a);
    println!("b: {}", b);
    println!("c: {}", c);
    println!("d: {}", d);

    // Appending to a String
    let mut s = String::from("foo");
    s.push_str("bar");   // append a &str
    s.push('!');         // append a single char
    println!("{}", s);   // foobar!

    // Concatenation with + (moves the left operand)
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2;  // s1 is moved here, s2 is borrowed
    println!("{}", s3);
}

String Slices and Indexing

Because Rust strings are UTF-8 encoded, you cannot index them with a plain integer — a single Unicode character can occupy 1 to 4 bytes, so s[0] would be ambiguous.

fn main() {
    let s = String::from("hello");

    // Use ranges for byte slices (be careful with multi-byte chars!)
    let hello = &s[0..3];
    println!("{}", hello); // hel

    // Iterate over characters safely
    let emoji = "Hello 🦀";
    for ch in emoji.chars() {
        print!("{} ", ch);
    }
    println!();

    // Iterate over raw bytes
    for b in "abc".bytes() {
        print!("{} ", b);
    }
    println!();

    // Count characters vs bytes
    let japanese = "日本語";
    println!("chars: {}", japanese.chars().count()); // 3
    println!("bytes: {}", japanese.len());            // 9
}

Note: Slicing with byte ranges that split a multi-byte character will panic at runtime. Always prefer .chars() or .char_indices() when iterating over text with non-ASCII content.

Common String Operations

The String and &str types come with a rich set of methods for searching, splitting, trimming, and transforming text.

fn main() {
    let sentence = "  Rust makes text handling safe and expressive.  ";

    // Trim whitespace
    let trimmed = sentence.trim();
    println!("{:?}", trimmed);

    // Check contents
    println!("starts with 'Rust': {}", trimmed.starts_with("Rust"));
    println!("contains 'safe': {}", trimmed.contains("safe"));

    // Split and collect
    let words: Vec<&str> = trimmed.split_whitespace().collect();
    println!("word count: {}", words.len());
    println!("first word: {}", words[0]);

    // Replace
    let replaced = trimmed.replace("safe", "powerful");
    println!("{}", replaced);

    // To uppercase / lowercase
    println!("{}", "Hello".to_uppercase());
    println!("{}", "WORLD".to_lowercase());

    // Parse a number from a string
    let n: i32 = "42".parse().expect("not a number");
    println!("parsed: {}", n);
}

Building Strings Efficiently

When constructing a string from many pieces, avoid repeated + concatenation — each + may allocate. Use format! for small cases, or push into a String buffer for larger ones.

fn main() {
    // Inefficient: each + may reallocate
    // let result = "a".to_string() + "b" + "c" + "d";

    // Better: format! for a few pieces
    let (first, last) = ("Jane", "Doe");
    let full_name = format!("{} {}", first, last);
    println!("{}", full_name);

    // Best for many pieces: push into a buffer
    let items = vec!["apple", "banana", "cherry"];
    let mut result = String::new();
    for (i, item) in items.iter().enumerate() {
        if i > 0 {
            result.push_str(", ");
        }
        result.push_str(item);
    }
    println!("{}", result); // apple, banana, cherry

    // Or use join() on a slice
    let joined = items.join(", ");
    println!("{}", joined); // apple, banana, cherry
}

Try It Yourself

Write a function that takes a sentence and returns a new string with every word capitalized (title case). Use .split_whitespace(), .chars(), and string building to construct the result.

fn title_case(s: &str) -> String {
    s.split_whitespace()
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => {
                    let upper: String = first.to_uppercase().collect();
                    upper + chars.as_str()
                }
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn main() {
    let input = "the quick brown fox";
    println!("{}", title_case(input)); // The Quick Brown Fox

    let input2 = "  rust is awesome  ";
    println!("{}", title_case(input2)); // Rust Is Awesome
}

Try extending this: handle punctuation at the end of words, or make it lowercase all non-first letters before capitalizing.

Key Takeaways

  • String is owned and heap-allocated; &str is a borrowed slice pointing to existing UTF-8 data
  • Rust strings are always valid UTF-8 — the compiler and runtime enforce this
  • You cannot index a string with a plain integer because characters may be multi-byte; use .chars() instead
  • Use format!() or a String push buffer to build strings efficiently; avoid chaining + for many pieces
  • Common methods like .trim(), .split_whitespace(), .contains(), .replace(), and .parse() cover most real-world needs
  • &String coerces automatically to &str — prefer &str in function parameters for maximum flexibility

Pro Tip: When writing a function that only reads string data, always take &str as the parameter type rather than &String. A &str accepts both string literals and &String references (via automatic deref coercion), making your function more reusable without any extra cost.

The lesson covers:

  • 5 playground code blocks (two string types, creating/growing, slicing/indexing, common operations, building efficiently)
  • Try It Yourself with a realistic title_case function
  • Key Takeaways with 6 bullets
  • Pro Tip blockquote at the end
  • All examples are self-contained, idiomatic Rust, and runnable in-browser

Next Steps

With strings mastered, you're ready to learn how to organize your code into modules and use external crates — Rust's package ecosystem.

Next lesson

Modules and Crates

Learn how to organize Rust code with modules and manage dependencies using crates and Cargo

25 min