Skip to main content

A Deep Dive into Strings in Rust

Published: November 7, 2025 Updated: August 28, 2026 Larry Qu 8 min read

Strings in Rust are one of the first things that confuse newcomers. Unlike most languages where strings are a single type, Rust has two primary string types with distinct ownership semantics. Once you understand why they exist, everything else clicks into place.

The Two String Types

Type Ownership Mutability Location Size
String Owned Mutable Heap Dynamic
&str Borrowed Immutable Anywhere Fixed (fat pointer)

Think of it this way: String is like a Vec<u8> — it owns its data, can grow, and lives on the heap. &str is like a &[u8] — a borrowed view into existing string data anywhere in memory.

&str — String Slices

A string slice is a reference to valid UTF-8 bytes with a known length. It doesn’t own the data — it points to it.

fn main() {
    // String literal — stored in binary, lives for 'static lifetime
    let s1: &'static str = "Hello, Rust!";

    // Slice of a String
    let owned = String::from("hello world");
    let hello: &str = &owned[0..5];  // Borrows part of owned
    let world: &str = &owned[6..];   // Rest of owned

    println!("{} {}", hello, world);  // hello world

    // owned is still valid — we only borrowed it
    println!("{}", owned);
}

Why &str in Function Parameters?

Accept &str instead of &String to make functions more flexible. A &String automatically coerces to &str (Deref coercion), so callers can pass either:

fn word_count(s: &str) -> usize {
    s.split_whitespace().count()
}

fn main() {
    let owned = String::from("the quick brown fox");
    let literal = "jumps over the lazy dog";

    println!("{}", word_count(&owned));   // &String → &str automatically
    println!("{}", word_count(literal));  // &'static str directly
}

This is the idiomatic Rust pattern. A function taking &String only works for owned strings; &str works for both.

String — Owned UTF-8 Text

String owns heap-allocated, mutable, UTF-8-encoded text. It’s the go-to when you need to create, modify, or return string data.

Creating Strings

fn main() {
    let s1 = String::new();                          // Empty
    let s2 = String::from("hello");                  // From literal
    let s3 = "hello".to_string();                    // Same as above
    let s4 = format!("Hello, {}!", "world");         // Formatted
    let s5: String = "hello".chars().collect();      // From iterator

    // From bytes (must be valid UTF-8)
    let bytes = vec![104, 101, 108, 108, 111]; // "hello"
    let s6 = String::from_utf8(bytes).unwrap();
    println!("{}", s6); // hello
}

Modifying Strings

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

    // Append
    s.push(',');                 // Append single char
    s.push_str(" world");        // Append string slice
    println!("{}", s);           // Hello, world

    // Concatenation with + (takes ownership of left side)
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2;  // s1 is moved, s2 is borrowed
    println!("{}", s3);
    // s1 is gone; s2 is still valid

    // format! — doesn't take ownership of anything
    let a = String::from("tic");
    let b = String::from("tac");
    let c = String::from("toe");
    let combined = format!("{a}-{b}-{c}");
    println!("{combined}"); // a, b, c all still valid

    // Replace
    let s = String::from("I like cats and cats");
    println!("{}", s.replace("cats", "dogs"));
    println!("{}", s.replacen("cats", "dogs", 1)); // only first occurrence

    // Case
    println!("{}", "hello WORLD".to_uppercase()); // HELLO WORLD
    println!("{}", "hello WORLD".to_lowercase()); // hello world

    // Trim
    let padded = "   hello   ";
    println!("'{}'", padded.trim());        // 'hello'
    println!("'{}'", padded.trim_start()); // 'hello   '
}

UTF-8: The Important Details

Both String and &str are guaranteed to be valid UTF-8. This has consequences for indexing.

Why You Can’t Index by Integer

Characters in UTF-8 can be 1–4 bytes. Indexing s[0] is ambiguous: do you want the first byte or the first character? For multi-byte characters they’re different:

fn main() {
    let s = String::from("Здравствуйте"); // Russian "Hello"

    // let c = s[0];   // ERROR — cannot index String by integer
    // let c = &s[0..1]; // PANIC — 'З' is 2 bytes, this splits a char!

    // Correct: slice by byte boundary (you must know the char boundaries)
    let first_two_chars = &s[0..4]; // 'З' is 2 bytes, 'д' is 2 bytes
    println!("{}", first_two_chars); // Зд
}

Three Ways to View String Data

fn main() {
    let s = "नमस्ते"; // Hindi "Namaste"

    // 1. Bytes — raw memory representation
    println!("Bytes: {}", s.bytes().count()); // 18 bytes

    // 2. Unicode scalar values (chars)
    println!("Chars: {}", s.chars().count());  // 6 chars

    // 3. Grapheme clusters (visual units, requires unicode-segmentation crate)
    // "स्" is two chars but one visual character (consonant + combining mark)

    // Iterate over chars
    for (i, c) in s.chars().enumerate() {
        println!("{}: {}", i, c);
    }

    // nth char safely
    let third = s.chars().nth(2);
    println!("{:?}", third); // Some('स')
}

String Operations

Searching and Testing

fn main() {
    let text = "The quick brown fox jumps over the lazy dog";

    // Contains
    println!("{}", text.contains("fox"));    // true
    println!("{}", text.starts_with("The")); // true
    println!("{}", text.ends_with("dog"));   // true

    // Find (returns byte index)
    println!("{:?}", text.find("fox"));      // Some(16)
    println!("{:?}", text.rfind("the"));     // Some(31)

    // Count occurrences
    let count = text.matches("the").count();
    println!("'the' appears {} times", count); // 1 (case sensitive)
    let count_ci = text.to_lowercase().matches("the").count();
    println!("'the' case-insensitive: {}", count_ci); // 2

    // Is empty / length
    let empty = "";
    println!("{} {}", empty.is_empty(), empty.len());
}

Splitting

fn main() {
    let csv = "alice,30,engineer";
    let fields: Vec<&str> = csv.split(',').collect();
    println!("{:?}", fields); // ["alice", "30", "engineer"]

    // Split and limit
    let s = "one two three four";
    let two: Vec<&str> = s.splitn(2, ' ').collect();
    println!("{:?}", two); // ["one", "two three four"]

    // Split on multiple delimiters
    let mixed = "hello world\tfoo\nbar";
    let words: Vec<&str> = mixed.split_whitespace().collect();
    println!("{:?}", words); // ["hello", "world", "foo", "bar"]

    // Lines
    let multiline = "line1\nline2\nline3";
    for line in multiline.lines() {
        println!("{}", line);
    }
}

Parsing

Strings are the universal input format. Parse into any type that implements FromStr:

fn main() {
    // Parse numbers
    let n: i32 = "42".parse().unwrap();
    let f: f64 = "3.14".parse().unwrap();
    let b: bool = "true".parse().unwrap();

    // Graceful error handling
    match "abc".parse::<i32>() {
        Ok(n)  => println!("Parsed: {}", n),
        Err(e) => println!("Failed: {}", e),
    }

    // Custom type parsing with FromStr
    use std::str::FromStr;

    #[derive(Debug)]
    struct Point { x: f64, y: f64 }

    impl FromStr for Point {
        type Err = String;
        fn from_str(s: &str) -> Result<Self, Self::Err> {
            let (x, y) = s.split_once(',').ok_or("missing comma")?;
            Ok(Point {
                x: x.trim().parse().map_err(|e| format!("{}", e))?,
                y: y.trim().parse().map_err(|e| format!("{}", e))?,
            })
        }
    }

    let p: Point = "1.5, 2.7".parse().unwrap();
    println!("{:?}", p); // Point { x: 1.5, y: 2.7 }
}

String Formatting

fn main() {
    // Basic
    let name = "Alice";
    let age = 30;
    println!("{} is {} years old", name, age);

    // Named arguments
    println!("{name} is {age} years old");

    // Debug format
    let v = vec![1, 2, 3];
    println!("{:?}", v);   // [1, 2, 3]
    println!("{:#?}", v);  // pretty-printed

    // Number formatting
    println!("{:08b}", 42);    // 00101010 (binary, width 8, zero-padded)
    println!("{:08x}", 255);   // 000000ff (hex, width 8)
    println!("{:.3}", 3.14159); // 3.142 (3 decimal places)
    println!("{:>10}", "right"); // right-aligned, width 10
    println!("{:<10}", "left");  // left-aligned
    println!("{:^10}", "center"); // centered

    // format! to String
    let s: String = format!("{}_{}", "hello", 42);
    println!("{}", s); // hello_42
}

Cow<'a, str> — Owned or Borrowed

Cow (Clone on Write) is a smart pointer that can hold either a borrowed &str or an owned String. Use it in APIs where you sometimes need to modify the string and sometimes can return it unchanged:

use std::borrow::Cow;

fn normalize(s: &str) -> Cow<'_, str> {
    if s.chars().all(|c| c.is_lowercase()) {
        Cow::Borrowed(s) // No allocation needed
    } else {
        Cow::Owned(s.to_lowercase()) // Allocate only when needed
    }
}

fn main() {
    let lower = normalize("already lowercase");
    let mixed = normalize("Mixed Case");

    println!("{}", lower); // Borrowed — no allocation
    println!("{}", mixed); // Owned — allocated
}

Common Patterns

Build a String Efficiently

fn main() {
    // Avoid: repeated String + &str (allocates for each concatenation)
    let parts = vec!["one", "two", "three", "four"];

    // Good: collect with join
    let joined = parts.join(", ");
    println!("{}", joined); // one, two, three, four

    // Good: use String::with_capacity to pre-allocate
    let mut result = String::with_capacity(100);
    for (i, part) in parts.iter().enumerate() {
        if i > 0 { result.push_str(", "); }
        result.push_str(part);
    }
    println!("{}", result);

    // Good: fold with format
    let csv: String = parts.iter()
        .enumerate()
        .fold(String::new(), |mut acc, (i, s)| {
            if i > 0 { acc.push(','); }
            acc.push_str(s);
            acc
        });
    println!("{}", csv);
}

String → Bytes and Back

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

    // String to bytes
    let bytes: Vec<u8> = s.into_bytes();
    println!("{:?}", bytes); // [104, 101, 108, 108, 111]

    // Bytes back to String
    let s2 = String::from_utf8(bytes).unwrap();
    println!("{}", s2); // hello

    // For potentially invalid UTF-8
    let bytes = vec![104, 101, 108, 108, 111, 255]; // 255 is invalid UTF-8
    match String::from_utf8(bytes) {
        Ok(s) => println!("{}", s),
        Err(e) => println!("Invalid UTF-8: {}", e),
    }

    // Lossy conversion (replaces invalid bytes with U+FFFD)
    let lossy = String::from_utf8_lossy(&[104, 255, 111]);
    println!("{}", lossy); // h<replacement char>o
}

Summary

  • Use &str for function parameters that just read string data — it accepts both &String and string literals
  • Use String when you own string data, need to build it dynamically, or return it from a function
  • UTF-8 means no integer indexing — use .chars() for characters, .bytes() for raw bytes, or slice on verified char boundaries
  • format! is the clearest way to combine strings without ownership headaches
  • Cow<str> when you want to defer allocation until you actually need to modify the string

Resources

Comments

👍 Was this article helpful?