Guide contents

Lesson 17 of 94

Strings in Depth: String vs &str

With ownership, references, and slices behind you, Rust's two string types finally make sense:

  • String is an owned, growable, heap-allocated buffer of UTF-8 text.
  • &str is a borrowed view of UTF-8 text owned by something else: a String, or the program binary for literals.
Task Rust
Read or own text &str for reading, String for owning
Build text String (with mut)
Get the nth character s.chars().nth(i) (there is no O(1) character indexing)
Get the length s.len() (UTF-8 bytes) or s.chars().count() (characters)
Take a substring &s[a..b] (byte range, borrowed)
Split text s.split(',') returns a lazy iterator
Concatenate s1 + &s2 (moves s1), or format!("{s1}{s2}")
Parse an integer s.parse::<i32>() returns a Result

The rule of thumb: functions take &str and return String. Structs store String.

1. The two types side by side

Rust
fn main() {
    let borrowed: &str = "I live in the binary";
    let owned: String = String::from("I live on the heap");
    let view: &str = &owned; // borrow the String as &str
    let also_view: &str = owned.as_str(); // same thing, spelled out
    println!("{borrowed}\n{owned}\n{view}\n{also_view}");
}
Output
I live in the binary
I live on the heap
I live on the heap
I live on the heap

2. Every way to make a String

Rust
fn main() {
    let a = String::from("from");
    let b = "to_string".to_string();
    let c = "to_owned".to_owned();
    let d: String = "into".into();
    let e = format!("{}-{}", "format", 1);
    let f = String::with_capacity(64); // empty, with room reserved
    let g = 'x'.to_string();
    let h = 42.to_string();
    println!("{a} {b} {c} {d} {e} [{f}] {g} {h}");
}
Output
from to_string to_owned into format-1 [] x 42

.into() converts a value into the type the context expects. Conversion Traits explains how it works.

3. Growing a String

Rust
fn main() {
    let mut s = String::from("Hello");
    s.push(',');
    s.push_str(" world");
    s.insert(0, '>');
    s.insert_str(1, "> ");
    println!("{s}");
}
Output
>> Hello, world

4. Removing from a String

Rust
fn main() {
    let mut s = String::from("[draft] Release notes!!!");
    s.pop();                          // "[draft] Release notes!!"
    let removed = s.remove(0);        // removes '[' -> "draft] Release notes!!"
    s.replace_range(0..6, "Final:");  // "draft]" -> "Final:"
    s.truncate(s.len() - 2);          // keep all but the last 2 bytes
    println!("{s} (removed {removed:?})");
    s.clear();
    println!("after clear: {:?}, capacity kept: {}", s, s.capacity() > 0);
}
Output
Final: Release notes (removed '[')
after clear: "", capacity kept: true

5. Concatenation with + moves the left side

a + &b takes ownership of a, appends b to it, and returns it. No new buffer is allocated if a has room. The right side must be a &str.

Rust
fn main() {
    let greeting = String::from("Hello, ");
    let name = String::from("Ferris");
    let message = greeting + &name + "!";
    println!("{message}");
    println!("name is still usable: {name}");
}
Output
Hello, Ferris!
name is still usable: Ferris

Using greeting afterwards is an error, because + moved it:

Rust✗ does not compile · E0382
fn main() {
    let greeting = String::from("Hello, ");
    let name = String::from("Ferris");
    let message = greeting + &name;
    println!("{greeting}");
}
Output
error[E0382]: borrow of moved value: `greeting`
 --> src/main.rs:5:16
  |
2 |     let greeting = String::from("Hello, ");
  |         -------- move occurs because `greeting` has type `String`, which does not implement the `Copy` trait
3 |     let name = String::from("Ferris");
4 |     let message = greeting + &name;
  |                   -------- value moved here
5 |     println!("{greeting}");
  |                ^^^^^^^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
4 |     let message = greeting.clone() + &name;
  |                           ++++++++

6. Prefer format! for combining several pieces

format! never takes ownership of its arguments and reads much better than a chain of +.

Rust
fn main() {
    let street = String::from("221B Baker Street");
    let city = "London";
    let postcode = String::from("NW1 6XE");
    let address = format!("{street}, {city} {postcode}");
    println!("{address}");
    println!("parts still usable: {street} / {postcode}");
}
Output
221B Baker Street, London NW1 6XE
parts still usable: 221B Baker Street / NW1 6XE

7. You cannot index a string by position

Rust✗ does not compile · E0277
fn main() {
    let s = String::from("hello");
    let first = s[0];
    println!("{first}");
}
Output
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:19
  |
3 |     let first = s[0];
  |                   ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
help: the following other types implement trait `SliceIndex<T>`
 --> /rustc/<version>/library/core/src/slice/index.rs:214:0
  |
  = note: `usize` implements `SliceIndex<[T]>`
 --> /rustc/<version>/library/core/src/bstr/traits.rs:203:0
  |
  = note: `usize` implements `SliceIndex<ByteStr>`
  = note: required for `String` to implement `Index<{integer}>`

Why? A Rust string is UTF-8, where one character takes 1 to 4 bytes. s[0] would be ambiguous (a byte? a character?), and finding the nth character requires scanning from the start, which Rust refuses to hide behind cheap-looking syntax.

8. Bytes versus characters

Rust
fn main() {
    let words = ["hello", "héllo", "日本", "🦀"];
    for w in words {
        println!("{w:>6}: {} bytes, {} chars", w.len(), w.chars().count());
    }
}
Output
 hello: 5 bytes, 5 chars
 héllo: 6 bytes, 5 chars
    日本: 6 bytes, 2 chars
     🦀: 4 bytes, 1 chars

len() is O(1) and counts bytes. chars().count() walks the string and counts characters.

9. Getting the nth character

Rust
fn main() {
    let word = "naïve";
    println!("{:?}", word.chars().nth(2));
    println!("{:?}", word.chars().nth(10));
    println!("first: {:?}, last: {:?}", word.chars().next(), word.chars().last());
}
Output
Some('ï')
None
first: Some('n'), last: Some('e')

10. Iterating over characters, bytes, and positions

Rust
fn main() {
    let s = "añb";
    for c in s.chars() {
        print!("{c} ");
    }
    println!();
    for b in s.bytes() {
        print!("{b} ");
    }
    println!();
    for (byte_index, c) in s.char_indices() {
        print!("{c}@{byte_index} ");
    }
    println!();
}
Output
a ñ b
97 195 177 98
a@0 ñ@1 b@3

char_indices gives each character with its byte position, which is exactly what you need to slice safely.

11. Truncating text safely

Slicing by bytes can panic on multi-byte characters (see Slices). To cut text to a number of characters, find the byte position of that character first.

Rust
fn truncate_chars(s: &str, max_chars: usize) -> &str {
    match s.char_indices().nth(max_chars) {
        Some((byte_index, _)) => &s[..byte_index],
        None => s,
    }
}

fn main() {
    println!("{}", truncate_chars("Zürich is lovely", 3));
    println!("{}", truncate_chars("🦀🦀🦀🦀", 2));
    println!("{}", truncate_chars("short", 10));
}
Output
Zür
🦀🦀
short

12. Comparing strings

== compares contents. < and > compare in byte order, which for ASCII is alphabetical with all uppercase letters before lowercase. For case-insensitive comparison of ASCII, use eq_ignore_ascii_case.

Rust
fn main() {
    let a = String::from("apple");
    let b = "apple";
    println!("{}", a == b);
    println!("{}", "apple" < "banana");
    println!("{}", "Zebra" < "apple");
    println!("{}", "HELLO".eq_ignore_ascii_case("hello"));
    println!("{}", "Straße".to_lowercase() == "straße");
}
Output
true
true
true
true
true

13. Case conversion handles all of Unicode

Rust
fn main() {
    println!("{}", "straße".to_uppercase());
    println!("{}", "ΟΔΥΣΣΕΥΣ".to_lowercase());
    println!("{}", "rust".to_ascii_uppercase());
}
Output
STRASSE
οδυσσευς
RUST

ß becomes SS, so uppercasing can change the length. Greek final sigma becomes ς at the end of a word.

14. Trimming

Rust
fn main() {
    let raw = "\t  padded value \n";
    println!("[{}]", raw.trim());
    println!("[{}]", raw.trim_start());
    println!("[{}]", raw.trim_end());
    println!("[{}]", "xx_id_xx".trim_matches('x'));
    println!("[{}]", "0004200".trim_start_matches('0'));
}
Output
[padded value]
[padded value
]
[	  padded value]
[_id_]
[4200]

All trim methods return a &str slice of the original. They never allocate.

15. Searching

Rust
fn main() {
    let log = "2026-09-27 ERROR payment failed: card declined";
    println!("{}", log.contains("ERROR"));
    println!("{}", log.starts_with("2026"));
    println!("{}", log.ends_with("declined"));
    println!("{:?}", log.find(':'));
    println!("{:?}", log.rfind(' '));
    println!("{:?}", log.find("WARN"));
    println!("{}", log.matches('e').count());
}
Output
true
true
true
Some(31)
Some(37)
None
4

find returns the byte position as Some(position), or None when not found.

16. Removing a known prefix or suffix

strip_prefix returns Some(rest) only if the prefix was there, so you can check and cut in one step.

Rust
fn main() {
    let header = "Bearer abc.def.ghi";
    println!("{:?}", header.strip_prefix("Bearer "));
    println!("{:?}", "Basic xyz".strip_prefix("Bearer "));
    println!("{:?}", "report.csv".strip_suffix(".csv"));
}
Output
Some("abc.def.ghi")
None
Some("report")

17. Splitting

split returns an iterator of &str slices. Loop over it, or collect() it into a Vec<&str> (explained in Iterators).

Rust
fn main() {
    let csv = "id,name,,email";
    let fields: Vec<&str> = csv.split(',').collect();
    println!("{:?}", fields);

    let words: Vec<&str> = "  many   spaces here ".split_whitespace().collect();
    println!("{:?}", words);

    let limited: Vec<&str> = "a=b=c".splitn(2, '=').collect();
    println!("{:?}", limited);

    println!("{:?}", "user@mail.example.com".split_once('@'));
    println!("{:?}", "a.b.c.txt".rsplit_once('.'));
}
Output
["id", "name", "", "email"]
["many", "spaces", "here"]
["a", "b=c"]
Some(("user", "mail.example.com"))
Some(("a.b.c", "txt"))

Rust's split takes a plain string or character, not a regular expression, and it keeps empty fields.

18. Splitting into lines

lines() handles both \n and \r\n line endings.

Rust
fn main() {
    let text = "first line\r\nsecond line\nthird line";
    for (n, line) in text.lines().enumerate() {
        println!("{}: {line}", n + 1);
    }
}
Output
1: first line
2: second line
3: third line

19. Replacing

Rust
fn main() {
    let path = "C:\\Users\\ada\\docs";
    println!("{}", path.replace('\\', "/"));
    println!("{}", "aaa".replacen("a", "b", 2));
    println!("{}", "-".repeat(20));
}
Output
C:/Users/ada/docs
bba
--------------------

20. Parsing numbers from strings

parse can fail, so it returns a Result: Ok(value) or Err(error). Result & the ? Operator covers Result properly. Here, unwrap_or supplies a fallback value.

Rust
fn main() {
    let port: u16 = "8080".parse().unwrap_or(80);
    let bad: u16 = "eighty".parse().unwrap_or(80);
    let too_big: u16 = "70000".parse().unwrap_or(80);
    let ratio: f64 = " 0.75 ".trim().parse().unwrap_or(0.0);
    println!("{port} {bad} {too_big} {ratio}");
    println!("{:?}", "12x".parse::<i32>());
}
Output
8080 80 80 0.75
Err(ParseIntError { kind: InvalidDigit })

parse::<i32>() names the target type with the turbofish ::<> instead of an annotation on the variable.

21. Bytes to text: from_utf8 can fail

Not every sequence of bytes is valid UTF-8, so converting bytes to a String returns a Result.

Rust
fn main() {
    let valid = vec![82, 117, 115, 116];
    let invalid = vec![0xFF, 0xFE];
    println!("{:?}", String::from_utf8(valid));
    println!("{}", String::from_utf8(invalid).is_err());
    println!("{}", String::from_utf8_lossy(&[72, 105, 0xFF]));
}
Output
Ok("Rust")
true
Hi�

from_utf8_lossy replaces invalid bytes with � instead of failing.

22. Reversing a string

Reverse by characters, not bytes, or multi-byte characters break.

Rust
fn main() {
    let word = "stressed";
    let reversed: String = word.chars().rev().collect();
    println!("{reversed}");
    let accented: String = "café".chars().rev().collect();
    println!("{accented}");
}
Output
desserts
éfac

23. Building strings efficiently

Each + or format! in a loop may reallocate. Reserving capacity and using push_str into one String avoids that.

Rust
fn main() {
    let items = ["alpha", "beta", "gamma", "delta"];
    let mut out = String::with_capacity(64);
    for (i, item) in items.iter().enumerate() {
        if i > 0 {
            out.push_str(", ");
        }
        out.push_str(item);
    }
    println!("{out} (len {}, capacity {})", out.len(), out.capacity());
}
Output
alpha, beta, gamma, delta (len 25, capacity 64)

24. &str parameters, String returns

A function that only reads text should take &str, which accepts every kind of text. A function that creates text should return String, handing ownership to the caller.

Rust
fn initials(full_name: &str) -> String {
    let mut result = String::new();
    for word in full_name.split_whitespace() {
        if let Some(c) = word.chars().next() {
            result.push(c.to_ascii_uppercase());
            result.push('.');
        }
    }
    result
}

fn main() {
    let owned = String::from("grace brewster hopper");
    println!("{}", initials(&owned));
    println!("{}", initials("alan turing"));
    println!("{}", initials(&owned[6..]));
}
Output
G.B.H.
A.T.
B.H.

25. Realistic example: URL slugs

Rust
fn slugify(title: &str) -> String {
    let mut slug = String::new();
    let mut last_was_dash = true; // avoids a leading dash
    for c in title.chars() {
        if c.is_ascii_alphanumeric() {
            slug.push(c.to_ascii_lowercase());
            last_was_dash = false;
        } else if !last_was_dash {
            slug.push('-');
            last_was_dash = true;
        }
    }
    if slug.ends_with('-') {
        slug.pop();
    }
    slug
}

fn main() {
    println!("{}", slugify("Rust for Java Developers: Part 1!"));
    println!("{}", slugify("  --Hello,   World--  "));
}
Output
rust-for-java-developers-part-1
hello-world

26. Realistic example: masking a card number

Keep the last four digits and replace the rest, ignoring spaces.

Rust
fn mask_card(number: &str) -> String {
    let digits: String = number.chars().filter(|c| c.is_ascii_digit()).collect();
    let visible = &digits[digits.len() - 4..];
    format!("{}{}", "*".repeat(digits.len() - 4), visible)
}

fn main() {
    println!("{}", mask_card("4111 1111 1111 1234"));
    println!("{}", mask_card("5500-0000-0000-0004"));
}
Output
************1234
************0004

27. Realistic example: parsing a CSV line with quoted fields

Real CSV fields may contain commas inside quotes. A small state machine walks the characters once.

Rust
fn parse_csv_line(line: &str) -> Vec<String> {
    let mut fields = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;
    for c in line.chars() {
        if c == '"' {
            in_quotes = !in_quotes;
        } else if c == ',' && !in_quotes {
            fields.push(current);
            current = String::new();
        } else {
            current.push(c);
        }
    }
    fields.push(current);
    fields
}

fn main() {
    let line = r#"1042,"Hopper, Grace",grace@navy.mil,"Arlington, VA""#;
    for (i, field) in parse_csv_line(line).iter().enumerate() {
        println!("field {i}: {field}");
    }
}
Output
field 0: 1042
field 1: Hopper, Grace
field 2: grace@navy.mil
field 3: Arlington, VA

Notice fields.push(current) moves the finished field into the vector, and current = String::new() gives the variable a fresh value, the pattern from Ownership.

28. Realistic example: a template renderer

Replace {{name}} placeholders with values. The template and values are borrowed, and the result is a new owned String.

Rust
fn render(template: &str, values: &[(&str, &str)]) -> String {
    let mut out = String::with_capacity(template.len());
    let mut rest = template;
    while let Some(start) = rest.find("{{") {
        out.push_str(&rest[..start]);
        let after = &rest[start + 2..];
        match after.find("}}") {
            Some(end) => {
                let key = after[..end].trim();
                let mut replaced = false;
                for (k, v) in values {
                    if *k == key {
                        out.push_str(v);
                        replaced = true;
                    }
                }
                if !replaced {
                    out.push_str("<missing>");
                }
                rest = &after[end + 2..];
            }
            None => {
                out.push_str(&rest[start..]);
                rest = "";
            }
        }
    }
    out.push_str(rest);
    out
}

fn main() {
    let template = "Hi {{ name }}, your order {{order}} ships {{when}}.";
    let values = [("name", "Ada"), ("order", "#8812")];
    println!("{}", render(template, &values));
}
Output
Hi Ada, your order #8812 ships <missing>.

while let repeats as long as the pattern matches. It is covered with if let in Pattern Matching.

29. Realistic example: word wrapping

Wrap text to a maximum line width without breaking words.

Rust
fn wrap(text: &str, width: usize) -> Vec<String> {
    let mut lines = Vec::new();
    let mut line = String::new();
    for word in text.split_whitespace() {
        let needed = if line.is_empty() { word.len() } else { line.len() + 1 + word.len() };
        if needed > width && !line.is_empty() {
            lines.push(std::mem::take(&mut line));
        }
        if !line.is_empty() {
            line.push(' ');
        }
        line.push_str(word);
    }
    if !line.is_empty() {
        lines.push(line);
    }
    lines
}

fn main() {
    let text = "Ownership rules are checked at compile time, so they cost nothing at runtime.";
    for line in wrap(text, 24) {
        println!("|{line:<24}|");
    }
}
Output
|Ownership rules are     |
|checked at compile time,|
|so they cost nothing at |
|runtime.                |

std::mem::take(&mut line) moves the finished line out and leaves an empty String behind, as shown in Mutable References.

30. Realistic example: a simple tokenizer

Splitting an arithmetic expression into numbers and operators. Each token is a slice of the input, so no text is copied.

Rust
fn tokenize(expr: &str) -> Vec<&str> {
    let mut tokens = Vec::new();
    let mut start: Option<usize> = None;
    for (i, c) in expr.char_indices() {
        if c.is_ascii_digit() || c == '.' {
            if start.is_none() {
                start = Some(i);
            }
            continue;
        }
        if let Some(s) = start {
            tokens.push(&expr[s..i]);
            start = None;
        }
        if "+-*/()".contains(c) {
            tokens.push(&expr[i..i + 1]);
        }
    }
    if let Some(s) = start {
        tokens.push(&expr[s..]);
    }
    tokens
}

fn main() {
    println!("{:?}", tokenize("12.5 * (3 + 40) / 2"));
}
Output
["12.5", "*", "(", "3", "+", "40", ")", "/", "2"]

Summary

  • String owns its text and can grow. &str borrows text owned elsewhere. Literals are &str.
  • Take &str parameters, return String, store String in structs.
  • Strings are UTF-8. len() counts bytes, and you cannot index by position. Use chars(), char_indices(), and bytes().
  • a + &b moves a. Prefer format! for several parts, and push_str in loops.
  • Search with contains, find, starts_with, strip_prefix; split with split, split_once, split_whitespace, lines; clean with trim and replace.
  • parse and String::from_utf8 return Result, because they can fail.

Next: Ownership & Borrowing with Collections, which applies everything so far to Vec, HashMap, and nested data.