Rust String vs &str: Heap Strings, Slices, Ownership and Function Signatures
Key takeaways
Compare Rust String and str: heap vs slice, borrowing vs ownership, function signatures, conversions, and common lifetime mistakes. Learn which to use in function parameters, struct fields, and return types.
The Core Distinction
Rust has two main string types and beginners often wonder which to use. The short answer:
String: an owned, heap-allocated, growable UTF-8 string buffer&str: a borrowed reference to a UTF-8 string slice (a view into memory you don’t own)
They solve different problems. String is for when you need to own and possibly modify text. &str is for when you just need to read text that lives somewhere else.
Strictly speaking, str itself is the type of the characters — a dynamically sized sequence of UTF-8 bytes with no fixed length known at compile time. Because it has no size, you can never hold a bare str in a variable; you always handle it behind some kind of pointer: &str, &mut str, Box<str>, Rc<str>, or Arc<str>. In everyday code “str” and “&str” are used interchangeably, but the distinction explains error messages like “the size for values of type str cannot be known at compilation time”, which you get if you write let x: str = ... or put str directly in a struct field.
Coming from C++ or Java, the mental map that helps me is: String ≈ std::string (owns a buffer, can grow), and &str ≈ std::string_view (pointer + length, no ownership). The big difference is that in Rust the compiler checks that the view never outlives the buffer, which is the bug string_view is infamous for.
fn main() {
let owned: String = String::from("hello"); // heap allocation
let slice: &str = "world"; // points into read-only static memory
let view: &str = &owned; // points into owned's heap buffer
println!("{} {}", owned, slice);
println!("{} {}", owned, view); // owned is still valid — we borrowed it
}
Memory Layout
Understanding the layout makes the rules feel less arbitrary.
String Layout
A String is essentially a wrapper around Vec<u8> that guarantees UTF-8 validity:
Stack: Heap:
┌─────────────────┐ ┌─────────────────────┐
│ ptr ────────────┼────►│ h e l l o │
│ len: 5 │ └─────────────────────┘
│ capacity: 8 │
└─────────────────┘
Three words on the stack: a pointer to heap data, the current length, and the allocated capacity.
&str Layout
A &str is a fat pointer — two words:
Stack:
┌─────────────────┐
│ ptr ────────────┼──► (points somewhere — static memory, String heap, local buffer)
│ len: 5 │
└─────────────────┘
Creating a &str from a String costs nothing — it just copies the pointer and length:
let s: String = String::from("hello world");
let slice: &str = &s[0..5]; // "hello" — no allocation, just a new fat pointer
Note that the range is in bytes, not characters. len() also returns bytes. For ASCII text the two coincide, which is why the mistake survives testing: "héllo".len() is 6, and &"héllo"[0..2] panics at runtime with byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3). The first time user-supplied names reach code that truncates with byte slicing, it tends to panic on the first accented or CJK name. Safe alternatives are s.get(0..n) (returns Option<&str> instead of panicking), s.char_indices() to find a valid boundary, or s.chars().take(n).collect::<String>() when you really want the first n characters and can afford an allocation.
For the same reason, s[0] does not compile at all: the type 'str' cannot be indexed by '{integer}'. Rust refuses to pretend that indexing a UTF-8 string is O(1) character access. Use s.as_bytes()[0] for the first byte or s.chars().next() for the first character.
Ownership and Borrowing in Practice
// Takes ownership — the caller's String is moved and dropped when this function returns
fn consume(s: String) {
println!("{}", s);
} // s dropped here
// Borrows — the caller keeps their data
fn print_it(s: &str) {
println!("{}", s);
}
fn main() {
let owned = String::from("hello");
print_it(&owned); // borrow — owned still valid
print_it("world"); // string literal works too — &'static str coerces to &str
consume(owned); // move — owned no longer valid after this
// println!("{}", owned); // compile error: value used after move
}
The key insight: &str is accepted from any string-like source — a String (via coercion), a string literal, or another &str. This makes &str parameters maximally flexible:
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
greet("Alice"); // &'static str literal — works
greet(&String::from("Bob")); // String reference — works
greet(&"Carol"[..]); // &str slice — works
}
If greet took String instead, callers would have to allocate a String even when they only have a literal.
The mechanism that makes greet(&String::from("Bob")) work is deref coercion: String implements Deref<Target = str>, so when the compiler sees a &String where a &str is expected, it inserts the * and re-borrow for you. This is also why taking &String as a parameter is an anti-pattern — Clippy’s ptr_arg lint flags it, because a &String parameter rejects string literals and slices while offering nothing &str does not (the only extra thing it exposes is capacity()). Coercion does not apply in generic contexts, though: a function fn f<T: AsRef<str>>(x: T) accepts both types through the trait instead, which is the pattern to use when you also want to accept owned values without forcing a borrow.
Conversion Patterns
fn main() {
// &str → String (allocation)
let s1: String = "hello".to_string();
let s2: String = String::from("hello");
let s3: String = "hello".to_owned();
let s4: String = format!("{}", "hello"); // most flexible, always allocates
// String → &str (no allocation)
let owned = String::from("world");
let view1: &str = &owned;
let view2: &str = owned.as_str();
let view3: &str = &owned[..]; // full slice
// Partial slice
let partial: &str = &owned[1..4]; // "orl" — zero allocation
// Mutation — only on String
let mut mutable = String::from("hello");
mutable.push_str(", world");
mutable.push('!');
println!("{}", mutable); // hello, world!
}
The four &str → String forms produce the same result; which one you see is mostly style. to_owned() and String::from are the most direct. to_string() goes through the Display trait, and for years it was measurably slower than to_owned() for &str; the standard library has since specialized it, so on current Rust they compile to the same allocation and copy. format! is the only one that parses a format string, so reserve it for real formatting.
Going the other way is always free, and &owned, as_str(), and &owned[..] are identical. I use as_str() in match expressions, where deref coercion does not kick in: match owned.as_str() { "yes" => ..., _ => ... } compiles, while match owned { "yes" => ... } fails with “expected String, found &str”.
Function Parameter Guidelines
The parameter type you choose determines who has to do the conversion:
// Read-only: always use &str
// Caller can pass literals, &String, or &str without converting
fn count_chars(s: &str) -> usize {
s.chars().count()
}
// Sink: function takes ownership and stores it
// Caller passes String directly (or a clone if they need to keep theirs)
struct Logger {
prefix: String,
}
impl Logger {
fn new(prefix: String) -> Self {
Logger { prefix } // stores ownership
}
}
// Mutation: must take &mut String — can't mutate through &str
fn shout(s: &mut String) {
s.make_ascii_uppercase();
s.push('!');
}
fn main() {
println!("{}", count_chars("hello")); // 5
println!("{}", count_chars(&String::from("world"))); // 5
let logger = Logger::new(String::from("[INFO]"));
let mut msg = String::from("hello");
shout(&mut msg);
println!("{}", msg); // HELLO!
}
Struct Fields
// Owned field — the struct owns the string data
struct User {
name: String,
email: String,
}
impl User {
fn new(name: impl Into<String>, email: impl Into<String>) -> Self {
User {
name: name.into(), // accepts both &str and String
email: email.into(),
}
}
// Return a view — no clone needed
fn name(&self) -> &str {
&self.name
}
}
// Borrowed field — the struct borrows from somewhere else
// Requires an explicit lifetime: the struct cannot outlive the borrowed data
struct UserRef<'a> {
name: &'a str,
}
fn main() {
// User owns its data — can be moved freely, no lifetime concerns
let u = User::new("Alice", "[email protected]");
println!("{}", u.name()); // returns &str — no allocation
// UserRef borrows — must not outlive the source
let name = String::from("Bob");
let uref = UserRef { name: &name };
println!("{}", uref.name);
// uref must go out of scope before name does
}
For most structs, prefer String fields. The ergonomics of lifetimes (UserRef<'a>) are worth it only when you are processing data in-place and want to avoid copies entirely — parsers, for example.
The cost of a &'a str field is not the annotation itself but how it spreads. Every struct that contains a UserRef<'a> needs its own 'a, every function that returns one needs a lifetime tied to an input, and you can no longer build the struct inside a function from a locally read file and return it — the file contents would be dropped. The typical symptom is a chain of errors like “name does not live long enough … borrowed value does not live long enough” that pushes you to add lifetimes in five places. When I hit that, the right move is almost always to switch the field to String and accept one allocation per field; it is rarely where the time goes.
impl Into<String> in User::new is a good middle ground for constructors: callers pass a literal without writing .to_string(), and callers who already own a String hand it over without a second allocation. If the struct holds many identical strings that are never mutated (tag names, interned identifiers), Arc<str> is worth knowing: it is two words instead of three and clones by bumping a reference count.
Return Type: When &str Works and When It Doesn’t
// GOOD: returning a slice of an input parameter
// Lifetime elision makes this fn first_word<'a>(s: &'a str) -> &'a str:
// the returned &str lives as long as the input
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(pos) => &s[..pos],
None => s,
}
}
// GOOD: returning a static literal
fn default_greeting() -> &'static str {
"Hello!"
}
// BAD: returning a reference to a local String
fn make_greeting(name: &str) -> &str {
let s = format!("Hello, {}!", name); // s is local
&s // compile error: cannot return reference to local variable
}
// CORRECT: return String when you create new data
fn make_greeting_correct(name: &str) -> String {
format!("Hello, {}!", name) // caller owns the result
}
fn main() {
let text = String::from("hello world how are you");
println!("{}", first_word(&text)); // "hello" — no allocation
println!("{}", default_greeting()); // "Hello!" — static
println!("{}", make_greeting_correct("Alice")); // "Hello, Alice!"
}
first_word compiles without explicit lifetimes because of the elision rules: with exactly one reference input, the output reference is assumed to borrow from it. Once a function takes two &str parameters and returns one, elision cannot guess, and you get missing lifetime specifier … this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from 'a' or 'b'. The fix is to name the lifetime explicitly (fn longer<'a>(a: &'a str, b: &'a str) -> &'a str), which is a promise the compiler then checks at every call site.
In make_greeting, adding a lifetime would not help — there is no input the result can borrow from. The actual error is cannot return reference to local variable 's' (E0515). No annotation can fix that; the data has to move out to the caller as a String.
Cow<str>: When You Sometimes Need to Own
Cow<'a, str> (“clone on write”) holds either a &str or a String and avoids the allocation when borrowing suffices:
use std::borrow::Cow;
fn sanitize(s: &str) -> Cow<'_, str> {
if s.contains('<') || s.contains('>') {
// Needs modification — allocate
Cow::Owned(s.replace('<', "<").replace('>', ">"))
} else {
// Clean — just borrow
Cow::Borrowed(s)
}
}
fn main() {
let clean = "Hello, world!";
let dirty = "Hello, <world>!";
println!("{}", sanitize(clean)); // borrows — no allocation
println!("{}", sanitize(dirty)); // allocates — returns Cow::Owned
}
Use Cow<str> in library APIs when the result is sometimes borrowed and sometimes owned — it gives callers maximum flexibility without forcing an allocation.
Over-owning parameters, hot-path to_string(), and dangling &str
Taking String When &str Suffices
// SLOW: forces allocation at every call site
fn log(msg: String) { println!("{}", msg); }
log("connecting".to_string()); // unnecessary allocation
// FAST: accepts anything string-like
fn log(msg: &str) { println!("{}", msg); }
log("connecting"); // literal — no allocation
Extra to_string() in Hot Paths
fn process(items: &[String]) {
for item in items {
// SLOW: allocates a new String every iteration
let lower = item.to_lowercase().to_string();
// FAST: to_lowercase() already returns String
let lower = item.to_lowercase();
// FASTEST: if you only need a comparison, don't build a String at all
if item.eq_ignore_ascii_case("admin") { /* ... */ }
}
}
to_lowercase() already allocates a new String (it has to — lowercasing can change the byte length for some Unicode characters), so the trailing .to_string() copies the result a second time for nothing. When the lowered string is only used for a comparison, eq_ignore_ascii_case compares in place with zero allocations; it only folds ASCII letters, which is what you want for protocol keywords, header names, and file extensions, but not for full Unicode case-insensitive matching.
Returning &str to Dropped Data
fn broken() -> &str { // compile error: missing lifetime
let s = String::from("hello");
&s // s will be dropped — dangling reference
}
// Fix: return String
fn fixed() -> String {
String::from("hello")
}
The borrow checker prevents dangling references at compile time. If you get a lifetime error on a return type, the usual fix is to return a String instead of &str.
Which string type to use where
| Scenario | Use |
|---|---|
| Read-only function parameter | &str |
| Function that stores the string | String (by value) or impl Into<String> |
| Struct field with owned data | String |
| Struct field with borrowed data | &'a str (requires lifetime parameter) |
| Return: new data created inside function | String |
| Return: slice of input parameter | &str |
| Sometimes borrowed, sometimes owned | Cow<'_, str> |
Frequently Asked Questions (FAQ)
Q. Why does the compiler reject a function that returns &str built from a local String?
A. A &str is only a borrowed view, and the local String it points into is dropped when the function returns, so the reference would dangle. The borrow checker reports this as a missing lifetime or “returns a value referencing data owned by the current function”. Return an owned String instead, or return Cow<str> if most calls can hand back a borrowed input unchanged.