Rust String vs str: String Types Explained
Rust's ownership model demands explicit handling of memory, and strings are no exception. Unlike languages with garbage collection where a single string type suffices, Rust distinguishes between...
Key Insights
&stris a borrowed view into UTF-8 data with a fixed size, whileStringis an owned, heap-allocated, growable buffer—understanding this distinction is fundamental to writing idiomatic Rust.- Function parameters should almost always accept
&strrather thanStringto maximize flexibility, whileStringshould be used when you need ownership or mutability. - Most string-related compiler errors stem from confusion about ownership and borrowing; learning when to use references versus owned values eliminates 90% of these issues.
Introduction: Why Two String Types?
Rust’s ownership model demands explicit handling of memory, and strings are no exception. Unlike languages with garbage collection where a single string type suffices, Rust distinguishes between borrowed string slices (&str) and owned strings (String). This isn’t complexity for its own sake—it’s a direct consequence of Rust’s zero-cost abstractions and memory safety guarantees.
The distinction boils down to ownership. String owns its data and can modify it. &str borrows data from somewhere else and provides read-only access. This separation allows Rust to enforce memory safety at compile time while giving you fine-grained control over allocation and performance.
fn main() {
let borrowed: &str = "Hello, world!"; // String slice (borrowed)
let owned: String = String::from("Hello, world!"); // Owned string
println!("{}", borrowed);
println!("{}", owned);
}
Both print the same output, but their memory characteristics differ fundamentally.
Understanding &str (String Slices)
A string slice (&str) is a reference to a sequence of UTF-8 bytes. It’s a “view” into string data stored elsewhere—either in the binary’s read-only memory (for string literals) or on the heap (when slicing a String). The & indicates it’s borrowed, meaning it doesn’t own the underlying data.
String literals have the type &'static str, where 'static indicates the data lives for the entire program duration. The compiler embeds these directly into your binary:
fn main() {
let literal: &'static str = "This lives in the binary";
// You can also slice from a String
let owned = String::from("Hello, Rust!");
let slice: &str = &owned[0..5]; // "Hello"
println!("{}", slice);
}
String slices are immutable. You cannot modify the underlying data through a &str. This immutability enables safe sharing—multiple parts of your code can hold &str references to the same data without risk of data races.
When writing functions, prefer &str parameters. This accepts both string literals and owned strings (via deref coercion):
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
greet("Alice"); // String literal
let owned_name = String::from("Bob");
greet(&owned_name); // Owned string (automatically coerced)
}
Understanding String (Owned Strings)
String is a heap-allocated, growable UTF-8 buffer. Unlike &str, a String owns its data, which means it’s responsible for allocating and deallocating memory. This ownership enables mutation and dynamic sizing.
Create String instances using String::from() or the .to_string() method:
fn main() {
let mut s1 = String::from("Hello");
let mut s2 = "World".to_string();
// Strings are mutable when declared with `mut`
s1.push_str(", Rust!");
s2.push('!');
println!("{}", s1); // "Hello, Rust!"
println!("{}", s2); // "World!"
}
String provides methods for modification that &str lacks. You can append characters, concatenate strings, insert at specific positions, and more:
fn main() {
let mut greeting = String::from("Hello");
greeting.push(' '); // Append single character
greeting.push_str("world"); // Append string slice
// Concatenation (note: takes ownership of the first string)
let hello = String::from("Hello, ");
let world = String::from("world!");
let message = hello + &world; // `hello` is moved, `world` is borrowed
println!("{}", message);
}
The + operator for string concatenation takes ownership of the left operand and borrows the right. This is efficient but can be surprising if you expect to use the first string afterward.
Memory Layout and Performance
The memory characteristics of these types differ significantly:
// Memory layout visualization
// &str: Two-word structure (fat pointer)
// ┌─────────┬────────┐
// │ ptr │ length │ <- Lives on stack
// └────┬────┴────────┘
// │
// └──> "Hello" <- Points to data (binary or heap)
// String: Three-word structure
// ┌─────────┬────────┬──────────┐
// │ ptr │ length │ capacity │ <- Lives on stack
// └────┬────┴────────┴──────────┘
// │
// └──> [H][e][l][l][o][...] <- Heap-allocated buffer
&str consists of a pointer and length—16 bytes on 64-bit systems. It points to data but doesn’t own it, so there’s no capacity field.
String adds a capacity field, totaling 24 bytes on the stack. The capacity indicates how much space is allocated on the heap, which may exceed the current length to allow growth without reallocation.
Performance implications:
- Creation: String literals (
&str) have zero runtime cost—they’re baked into the binary. Creating aStringrequires heap allocation. - Passing to functions: Passing
&stris cheap (two words). PassingStringby value transfers ownership and may involve moves. - Modification: Only
Stringsupports modification. Growing aStringmay trigger reallocation if capacity is exceeded.
For read-only operations, &str is more efficient. Use String only when you need ownership or mutability.
Conversions and Common Patterns
Converting between types is straightforward but has performance implications:
fn main() {
// &str to String (allocates)
let slice: &str = "hello";
let owned1: String = slice.to_string();
let owned2: String = String::from(slice);
let owned3: String = slice.to_owned();
// String to &str (zero cost)
let owned = String::from("hello");
let slice1: &str = &owned; // Deref coercion
let slice2: &str = owned.as_str(); // Explicit conversion
}
Converting &str to String allocates heap memory and copies the data. Converting String to &str is free—it’s just borrowing.
Deref coercion automatically converts &String to &str in function calls. This is why functions accepting &str work with String references:
fn process(s: &str) {
println!("{}", s);
}
fn main() {
let owned = String::from("test");
process(&owned); // &String coerces to &str
}
For maximum flexibility, write generic functions using AsRef<str>:
fn print_string<S: AsRef<str>>(s: S) {
println!("{}", s.as_ref());
}
fn main() {
print_string("literal"); // &str
print_string(String::from("owned")); // String
print_string(&String::from("reference")); // &String
}
This pattern accepts any type that can be viewed as a string slice, eliminating the need for explicit conversions at call sites.
Best Practices and Guidelines
Follow these rules for idiomatic Rust:
Function parameters: Use &str unless you need ownership. This maximizes flexibility and avoids unnecessary allocations:
// Good: Accepts any string-like input
fn process_name(name: &str) {
println!("Processing: {}", name);
}
// Bad: Forces caller to allocate or give up ownership
fn process_name_bad(name: String) {
println!("Processing: {}", name);
}
Return values: Return String when creating new data, &str when returning slices of existing data:
fn create_greeting(name: &str) -> String {
format!("Hello, {}!", name) // New allocation
}
fn get_first_word(text: &str) -> &str {
text.split_whitespace().next().unwrap_or("")
}
Struct fields: Use String for owned data, &str only when the struct is tied to specific lifetimes:
// Good: Owns its data
struct User {
name: String,
email: String,
}
// Only when necessary (adds lifetime complexity)
struct UserView<'a> {
name: &'a str,
email: &'a str,
}
Refactoring example showing optimal type choice:
// Before: Unnecessarily restrictive
fn create_message(prefix: String, name: String) -> String {
format!("{}: {}", prefix, name)
}
// After: Flexible and efficient
fn create_message(prefix: &str, name: &str) -> String {
format!("{}: {}", prefix, name)
}
Common Pitfalls and Solutions
Pitfall 1: Unnecessary conversions. Don’t call .to_string() when you already have a compatible type:
// Bad: Unnecessary allocation
fn greet(name: &str) {
let owned = name.to_string();
println!("Hello, {}", owned);
}
// Good: Use the slice directly
fn greet(name: &str) {
println!("Hello, {}", name);
}
Pitfall 2: Cloning in loops. Avoid repeated allocations:
// Bad: Allocates on every iteration
fn process_items(items: &[&str]) {
for item in items {
let owned = item.to_string();
// Use owned...
}
}
// Good: Only allocate when necessary
fn process_items(items: &[&str]) {
for item in items {
// Work with &str directly when possible
if needs_modification(item) {
let mut owned = item.to_string();
// Modify owned...
}
}
}
Pitfall 3: Lifetime confusion. The compiler will guide you, but understanding helps:
// This won't compile: returning reference to local String
fn bad_function() -> &str {
let s = String::from("hello");
&s // Error: `s` is dropped at end of function
}
// Solution: Return owned String
fn good_function() -> String {
String::from("hello")
}
When the compiler complains about lifetimes with strings, ask yourself: “Who owns this data?” If it’s created in the function, return String. If it’s borrowed from a parameter, ensure the lifetime is correct.
Understanding the String vs &str distinction transforms how you write Rust. Start with &str by default for parameters, use String when you need ownership or mutation, and let the compiler guide you when you’re unsure. This mental model will make string handling second nature.