When to Use String vs &str

A Lifetime Perspective

Use String for owned, mutable text and &str for borrowed, read-only text slices.

When one owner isn't enough

You are writing a config loader for a CLI tool. You read a file into memory, parse the lines, and extract a name field. You want to store that name in your Config struct. You also want to pass the raw line to a logging function for debugging. You write the code, hit compile, and the borrow checker rejects you. The error points to a lifetime mismatch. You tried to hand out a slice of data that might disappear, or you tried to store a view inside a struct without telling the compiler how long that view is valid.

The issue is ownership. Rust forces you to decide: does this piece of text own its bytes, or is it just a window into bytes owned by something else? String owns the data. &str borrows it. Confusing the two leads to dangling pointers in other languages. Rust catches this at compile time, but you need to understand the distinction to write code that compiles and makes sense.

Concept in plain words

Think of String as a canvas you bought. You own the canvas. You can paint on it, stretch it to make it larger, or throw it in the trash. The canvas exists independently of anyone looking at it.

&str is a window frame clipped to a specific region of that canvas. The window doesn't own the paint. It just shows you what's behind it. You can look through the window, but you can't paint on the window itself. If someone moves the canvas or throws it away, the window is now looking at empty air. The window is invalid.

Rust tracks this with lifetimes. The lifetime of a &str is a promise: "This reference is valid only as long as the data it points to is alive." If you try to keep the window after the canvas is gone, the compiler stops you.

String allocates memory on the heap. It manages that memory and frees it when the String goes out of scope. &str is a reference to data stored elsewhere. That data might be inside a String, inside a string literal, or inside a byte buffer. The &str itself is just a pointer and a length. It carries no capacity and cannot grow.

Minimal example

Here is the basic interaction. You create an owned String, then slice it to get a borrowed &str.

fn main() {
    // String allocates on the heap. It owns the bytes.
    // The variable `owned` holds a pointer, length, and capacity.
    let owned = String::from("hello world");

    // &str is a slice. It borrows the data.
    // The lifetime ties this reference to `owned`.
    // If `owned` is dropped, `view` becomes invalid.
    let view: &str = &owned[6..];

    println!("{view}"); // prints "world"
}

The &owned[6..] syntax creates a &str pointing at the substring starting at index 6. The compiler infers that view cannot outlive owned. You don't need to write lifetime annotations here because the rules are simple. The borrow checker handles it automatically.

Memory layout and what happens

When you create a String, Rust allocates a buffer on the heap. The String struct on the stack contains three fields: a pointer to the heap buffer, the current length, and the capacity. The capacity is how much room is allocated. If you push characters and the length exceeds the capacity, Rust allocates a larger buffer, copies the data, and frees the old one.

A &str is smaller. It has only two fields: a pointer and a length. There is no capacity. A slice cannot grow. If you try to append to a &str, the compiler rejects it. You can only read.

The pointer in a &str points into the heap buffer owned by the String. It might point to the start, or to an offset. The length tells you how many bytes are valid from that pointer.

When the String goes out of scope, its destructor runs. It frees the heap buffer. Any &str pointing into that buffer is now a dangling pointer. The lifetime system prevents this. The compiler ensures that every &str is dropped before the String it references is dropped.

Realistic example

In real code, you often need to store text in a struct. If you store &str, the struct must carry a lifetime parameter. This forces the caller to ensure the data lives long enough. If you store String, the struct owns the data and manages its own lifetime.

/// A config struct that owns its data.
/// No lifetime parameters needed.
struct Config {
    name: String,
}

/// Parses a raw line and returns an owned Config.
/// Takes &str to accept both String and &str arguments.
fn parse_config(raw: &str) -> Config {
    // Extract the value after the '='.
    // This returns a &str slice of `raw`.
    let name_slice = raw.split('=').nth(1).unwrap();

    // Convert the slice to an owned String.
    // This allocates new memory and copies the bytes.
    let name = name_slice.to_string();

    Config { name }
}

fn main() {
    let line = "name=Alice";
    // `line` is a &str. parse_config accepts &str.
    let config = parse_config(line);

    // `line` can be dropped here.
    // `config` still owns its copy of "Alice".
    drop(line);

    println!("Config name: {}", config.name);
}

The function parse_config takes &str. This is a community convention. Writing the parameter as &str allows the function to accept String, &String, &str, and string literals. The compiler uses auto-deref to convert String to &str automatically. Inside the function, you call .to_string() to create an owned copy. This allocates memory and copies the bytes. The Config struct owns that memory. The caller can drop the original data immediately. The Config remains valid.

If you tried to store &str in Config without a lifetime, the compiler would reject it. The struct would need to know where the data comes from and how long it lives. Adding a lifetime to the struct couples it to the caller's data. That coupling makes the API harder to use. Own the data when you can.

Pitfalls and compiler errors

You will hit errors when you mix ownership and borrowing incorrectly. The compiler messages are precise. Read them.

Returning a reference to local data. You create a String inside a function and try to return a &str slice of it. The String is dropped at the end of the function. The slice would point to freed memory. The compiler rejects this with E0515 (returned value contains a reference to a local variable). You must return the String instead, or return a &str that references data passed in by the caller.

fn bad_function() -> &str {
    let s = String::from("hello");
    // E0515: returned value contains a reference to a local variable
    &s
}

Modifying while borrowed. You hold a &str slice of a String, then try to push to the String. Pushing might reallocate the buffer. If the buffer moves, the slice pointer becomes invalid. The compiler stops you with E0502 (cannot borrow as mutable because it is also borrowed as immutable). Drop the slice before modifying the String.

fn main() {
    let mut s = String::from("hello");
    let view = &s[..];
    // E0502: cannot borrow `s` as mutable because it is also borrowed as immutable
    s.push_str(" world");
}

Dangling references in structs. You store a &str in a struct, but the source data is dropped. The compiler catches this with lifetime errors like E0597 (borrowed value does not live long enough). The struct requires the data to live as long as the struct. If the data is shorter-lived, the code won't compile.

Don't fight the borrow checker here. Clone the data if you need to outlive the source. Use String in structs unless you have a compelling reason to avoid allocation.

Decision: when to use String vs &str

Use String when you need to own the text data. Use String when you must modify the content, append characters, or grow the buffer. Use String when storing text inside a struct that should manage its own lifetime without external constraints. Use String when the data must outlive the scope where it was created.

Use &str when you only need to read the text and the data lives elsewhere. Use &str as a function parameter to accept both string literals and String slices without forcing the caller to allocate. Use &str when slicing a larger buffer to avoid copying bytes. Use &str when passing text to functions that don't need ownership.

Use &'static str for string literals hardcoded in your source. These live for the entire duration of the program. Use &'static str for constants that are known at compile time.

Convention aside: write function parameters as &str. This keeps the API flexible. If the function needs to own the data, clone it inside with .to_string(). This is the standard pattern in the Rust ecosystem. Another convention: use .to_string() to convert &str to String. It reads like English. to_owned() does the same thing but is less specific. The community prefers the explicit name.

Where to go next