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. AcceptStringonly 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('<', "<")) // 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
&strandStringinputsWhen not to: If you always allocate, just return
String.Cowis 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
| Type | Owns data | Allocates | Use when |
|---|---|---|---|
&str | No | No | Reading, function params, returning slices |
String | Yes | Yes | Building, storing, modifying strings |
Cow<str> | Either | Sometimes | Optionally transforming, zero-copy fast paths |
impl Into<String> | — | Maybe | Accepting either type in a constructor |
impl AsRef<str> | — | No | Reading either type in a function |
Key Takeaways
- Accept
&strin function parameters when you only need to read. It works with literals,String, and slices without requiring the caller to allocate. - Return
Stringwhen you build the value. Return&stronly when slicing into the caller's data with a tied lifetime. - Use
format!for one-shot construction andpush_strwithwith_capacityfor 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&strandStringwithout manual conversion.