Strings: Choosing the Right Type

String handling in Rust trips up developers coming from almost every other language. Rust exposes a distinction that most languages hide: the difference between a string you own and a string you borrow a view into. Once that distinction is clear, the type system becomes a guide rather than an obstacle.

This tutorial covers &str, String, and Cow<str>: what each one represents and how to choose between them.


The Core Distinction

String is a heap-allocated, growable, owned string. It manages its own memory.

&str is a borrowed reference to a sequence of UTF-8 bytes stored somewhere else: in a String, in a string literal baked into the binary, or in any contiguous block of memory. It does not own the data.

let owned: String = String::from("hello");        // owns the bytes
let borrowed: &str = "world";                     // points into the binary
let slice: &str = &owned[0..3];                   // points into the String above

A &String is almost never what you want. It is a reference to a String, but since String derefs to &str, you can always use &str instead. It also works with literals and slices.


Scenario 1 - Function parameters: prefer &str

The Common Mistake

fn greet(name: String) {
    println!("Hello, {}!", name);
}

greet(String::from("Alice"));  // OK
greet("Bob");                  // ERROR: expected String, found &str

Accepting String forces every caller to allocate, even when they have a &str or literal on hand.

The Pattern

Accept &str for read-only string parameters. It works with literals, String (via auto-deref), and slices. No allocation required from the caller.

fn greet(name: &str) {
    println!("Hello, {}!", name);
}

greet("Alice");                        // works
greet(&String::from("Bob"));           // works — String derefs to &str
greet(&some_string[0..5]);             // works — slice

Rule of thumb: If a function only needs to read the string, accept &str. Accept String only when the function needs to own or modify it.


Scenario 2 - Return types: return String when you build the value

If a function constructs or transforms a string, return String. The caller takes ownership of the result.

fn full_name(first: &str, last: &str) -> String {
    format!("{} {}", first, last)
}

Return &str only when slicing into data the caller already owns, with a lifetime that ties the output to the input.

fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

Here the returned &str borrows from s. The lifetime is implicit but correct: the slice cannot outlive the string it came from.

Trying to return a &str that points into a locally created String is the classic mistake. The String is dropped at the end of the function, leaving a dangling reference. The compiler will catch this:

fn broken() -> &str {
    let s = String::from("hello");
    &s  // ERROR: `s` does not live long enough
}

Scenario 3 - Building strings incrementally

When you need to assemble a string from parts, String is the right type.

let mut result = String::new();
result.push_str("Hello");
result.push(',');
result.push_str(" world");

For one-shot formatting, format! is cleaner:

let result = format!("{}, {}", "Hello", "world");

format! always allocates a new String. If you are appending inside a loop, prefer push_str on a pre-allocated String to avoid repeated allocations:

let words = vec!["one", "two", "three"];
let mut output = String::with_capacity(32);  // pre-allocate to avoid reallocations

for (i, word) in words.iter().enumerate() {
    if i > 0 { output.push_str(", "); }
    output.push_str(word);
}

String::with_capacity is a small optimization that avoids reallocation as the string grows. Use it when you have a reasonable estimate of the final size.


Scenario 4 - Sometimes borrowed, sometimes owned: Cow<str>

Cow<str> (Clone On Write) holds either a borrowed &str or an owned String, and presents a unified interface for both. It is the right type when a function might need to allocate, but often does not.

A Concrete Example

You want to sanitize user input by replacing certain characters. If the input has none of those characters, skip the allocation and return the original.

use std::borrow::Cow;

fn sanitize(input: &str) -> Cow<str> {
    if input.contains('<') {
        Cow::Owned(input.replace('<', "&lt;"))  // allocates only when needed
    } else {
        Cow::Borrowed(input)                    // zero-copy fast path
    }
}

The caller receives a Cow<str> and uses it like a &str. Deref makes this transparent:

let result = sanitize("hello <world>");
println!("{}", result);   // works regardless of which variant it is

If the caller needs a String, they call .into_owned():

let owned: String = sanitize(input).into_owned();

When to use Cow<str>:

  • Functions that optionally transform strings
  • Returning data that is sometimes a slice of the input and sometimes a modified copy
  • Public APIs where callers have both &str and String inputs

When not to: If you always allocate, just return String. Cow is only valuable when the borrowed path is real and common.


Scenario 5 - Accepting both String and &str with Into<String>

Sometimes you want a function that accepts either type and takes ownership, for example to store it in a struct.

struct Config {
    host: String,
}

impl Config {
    fn new(host: impl Into<String>) -> Self {
        Config { host: host.into() }
    }
}

Config::new("localhost");            // &str — converts via Into
Config::new(String::from("prod"));  // String — no-op conversion

impl Into<String> accepts anything that can be converted into a String. The caller does not need to manually call .to_string(). This pattern is common in builder APIs and struct constructors.

AsRef<str> is the read-only equivalent. Use it when you only need to read, not own:

fn log(msg: impl AsRef<str>) {
    println!("[log] {}", msg.as_ref());
}

log("static message");
log(String::from("dynamic message"));

Quick Reference

TypeOwns dataAllocatesUse when
&strNoNoReading, function params, returning slices
StringYesYesBuilding, storing, modifying strings
Cow<str>EitherSometimesOptionally transforming, zero-copy fast paths
impl Into<String>MaybeAccepting either type in a constructor
impl AsRef<str>NoReading either type in a function

Key Takeaways

  • Accept &str in function parameters when you only need to read. It works with literals, String, and slices without requiring the caller to allocate.
  • Return String when you build the value. Return &str only when slicing into the caller's data with a tied lifetime.
  • Use format! for one-shot construction and push_str with with_capacity for incremental building in loops.
  • Use Cow<str> when a function conditionally transforms its input. The borrowed path stays zero-copy, the owned path allocates only when necessary.
  • Use impl Into<String> in constructors to accept both &str and String without manual conversion.