Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Strings and str

Intermediate · Abstractions

What & why

Rust has two main text types and beginners bump into both on day one: String (text your program owns and can grow) and &str (a borrowed view into some text). Once you see why there are two, the endless “expected &str, found String” errors stop being mysterious and start being obvious.

The idea, slowly

Owned vs borrowed, one more time

You already met this split in the Ownership lessons, just with different types. Text is the same story:

  • String is a growable, heap-allocated buffer your variable owns. Think of it as a whiteboard you bought — it’s yours, you can write more on it, erase it, and when you’re done it gets thrown away.
  • &str (say “string slice”) is a borrowed look at text that already exists somewhere. Think of it as pointing at words on someone else’s whiteboard — you can read them, but you don’t own the board and can’t grow it.
fn main() {
    let owned: String = String::from("hello"); // owns a growable buffer
    let borrowed: &str = &owned;                // borrows a view of it

    println!("owned = {}", owned);
    println!("borrowed = {}", borrowed);
}

&owned borrows the String and hands you a &str looking into it. Nothing is copied; borrowed just points at the same letters owned holds.

String literals are already &str

Every time you type text in quotes, that’s a &str — it points into your compiled program, which stays alive the whole time it runs:

fn main() {
    let greeting = "hi there"; // type is &str, no String involved
    println!("{}", greeting);
}

So "hi" is a &str, and String::from("hi") turns that borrowed text into an owned String you can grow.

Growing a String

Only String can grow, because only String owns its buffer:

fn main() {
    let mut name = String::from("Shamir");
    name.push_str("ul");     // add several chars
    name.push('!');          // add one char (note single quotes)
    println!("{}", name);    // Shamirul!
}

push_str takes a &str (a borrowed piece of text to append), and push takes a single char. Try this on a plain &str and it won’t compile — a borrowed view has nothing of its own to grow.

The function-argument rule of thumb

This is the practical payoff. When a function just needs to read text, take &str. It’s the more flexible choice because both a String and a &str can be passed to it:

fn shout(text: &str) -> String {
    text.to_uppercase()
}

fn main() {
    let owned = String::from("hello");
    println!("{}", shout(&owned));  // pass a String by reference -> &str
    println!("{}", shout("world")); // pass a literal &str directly
}

shout accepts &str, so it works for owned strings (via &owned) and literals. If you’d written fn shout(text: String), you’d force every caller to hand over an owned String and give it away. Taking &str is friendlier. Take &str to read; return String when you build new text.

Length is in bytes, not letters

This one surprises everyone. Rust text is UTF-8, where some characters take more than one byte. .len() counts bytes:

fn main() {
    let word = "café";
    println!("bytes: {}", word.len());          // 5, not 4 — é is 2 bytes
    println!("chars: {}", word.chars().count()); // 4 actual characters
}

Because of this, you also can’t index text by numberword[0] is a compile error in Rust, on purpose, because “byte 0” and “character 0” aren’t always the same thing. To walk characters, use .chars().

Common mistakes

  • expected &str, found String (or vice versa). A function wanting &str won’t silently take a String. Pass &my_string to borrow it down to a &str. Going the other way, turn a &str into a String with .to_string() or String::from(...).
  • Trying to grow a &str. push_str/push need an owned buffer, so they only exist on String. The fix is to start from a String, or convert with .to_string().
  • Indexing text with [i]. s[0] doesn’t compile for strings because byte positions and character positions differ in UTF-8. Use .chars().nth(i) for a character, or slice by a known byte range.
  • Assuming .len() is the character count. It’s the byte count. For visible characters use .chars().count().
  • Taking String as a parameter when you only read it. This forces callers to give up ownership for no reason. Prefer &str for read-only text arguments.

More examples

Cleaning up user input

Form fields arrive messy — stray whitespace, inconsistent casing. Normalize them before you compare or store them.

fn main() {
    let raw_input = "   Alice@Example.com  \n";
    let clean = raw_input.trim().to_lowercase();
    println!("clean email: '{}'", clean);
}

Splitting a CSV-like line

Config files and simple data dumps are often just comma-separated fields. .split() plus .trim() handles the common case without pulling in a CSV crate.

fn main() {
    let line = "Ferris, 8, Crab";
    let fields: Vec<&str> = line.split(',').map(|f| f.trim()).collect();
    println!("{:?}", fields); // ["Ferris", "8", "Crab"]
}

Router-style path matching

A tiny web framework has to decide which handler owns a request path. starts_with/ends_with are the bread and butter of that decision.

fn main() {
    let path = "/api/users/42";

    if path.starts_with("/api/users/") {
        println!("route: get user");
    } else if path.ends_with(".json") {
        println!("route: serve json file");
    } else {
        println!("route: not found");
    }
}

Building a receipt line by line

Sometimes you don’t have all the text up front — you build it as you go, like assembling a shopping list into one printable line.

fn main() {
    let items = vec!["eggs", "milk", "bread"];
    let mut receipt = String::new();

    for item in &items {
        receipt.push_str(item);
        receipt.push_str(", ");
    }

    println!("{}", receipt); // eggs, milk, bread,
}

A helper that formats log lines

Utility functions like this get called with all kinds of text — owned Strings built at runtime, and &str literals. Taking &str parameters means one function serves both.

fn log_line(level: &str, message: &str) -> String {
    format!("[{}] {}", level.to_uppercase(), message)
}

fn main() {
    let msg = String::from("server started");
    println!("{}", log_line("info", &msg));
    println!("{}", log_line("warn", "disk almost full"));
}

Your turn

This function should return the text in uppercase, and be callable with both a String and a literal. It doesn’t compile. Fix the parameter type.

fn loud(text: String) -> String {
    text.to_uppercase()
}

fn main() {
    let name = String::from("rust");
    println!("{}", loud(&name)); // passing &name (a &str) — type mismatch
    println!("{}", loud("go"));  // passing a literal &str — type mismatch
}
Show solution

main passes borrowed text (&name and the literal "go"), both of which are &str. Make the function accept &str:

fn loud(text: &str) -> String {
    text.to_uppercase()
}

fn main() {
    let name = String::from("rust");
    println!("{}", loud(&name));
    println!("{}", loud("go"));
}

Accepting &str lets the function read either an owned String (borrowed with &) or a literal, without taking ownership.

Quick check

Remember this

  • String owns growable text; &str borrows a view of existing text.
  • String literals like "hi" are already &str.
  • Only String can grow (push_str, push) — a &str has nothing of its own to grow.
  • For read-only text arguments, take &str; it accepts both String (via &) and literals.
  • .len() is bytes, not characters; you can’t index text by number — use .chars().

Go deeper

Next: