String vs str in Rust: The Only Guide You'll Ever Need
--- Never Be Confused by String and str Again ---
Rust strings confused the hell out of me.
When I first started learning Rust, I kept seeing code that used String, str, &String, and &str—sometimes all in the same function. Sometimes I could pass a &String to a function that wanted a &str, and other times I had to use .to_string() just to get things to compile. Honestly, it felt pretty messy.
Let me show you what I mean:
fn print_message(msg: &str) {
println!("{}", msg);
}
fn main() {
let s1 = String::from("hello");
let s2 = "world";
print_message(&s1); // works!
print_message(s2); // also works!
// print_message(s1); // error: expected `&str`, found `String`
print_message(&s1[0..3]); // works! (a slice of s1)
}
Why does Rust let you pass some things, but not others? Why do we need so many string types? If you've ever been confused by this, you're not alone.
In this post, I'll explain why Rust strings work this way (and why it actually makes sense). Then we'll dive deeper to see what's really going on inside—right down to how these types are stored in memory.
1. What Do We Actually Need from Strings?
Let's step back and think about what we typically need strings to do:
Create and modify strings: Imagine building a dynamic welcome message. You have the text "Welcome," and you want to add different usernames depending on who logs in.
Efficiently borrow existing strings: Suppose you want to read a user's name directly from a configuration file or from a JSON response without making extra copies.
Use fixed text efficiently: Consider situations like logging simple messages such as "Operation completed successfully," which remain unchanged during the program's life.
A good string type in any programming language should handle these tasks efficiently. But using just one type usually forces compromises in performance, simplicity, or safety.
Rust addresses these needs clearly by providing two separate types. But how exactly?
2. What Exactly Are String and str?
String: Owned, Flexible, Heap-Allocated Text
A String owns its data, meaning it stores text on the heap and can grow or shrink as needed.
let mut message = String::new();
message.push_str("hello");
message.push('!'); // message is now "hello!"
str: Borrowed, Read-Only Views of Text
str is known as a "string slice"—an immutable view into UTF-8 text. You can't directly create or store a bare str because Rust needs to know the size of variables at compile time. That's why it's always used as &str, a borrowed reference.
let full_message = String::from("hello world");
let first_word: &str = &full_message[0..5]; // "hello"
A &str can reference text from anywhere: the heap (like a String), stack memory, or static memory embedded in your program.
To help you understand the differences between them better and when to use each one, let's peek inside—what do they look like in memory?
3. Diving Deeper: Memory Layout
Here's how these types appear internally:
Memory Layout of String:
let mut s1 = String::with_capacity(6);
s1.push_str("hello");
Memory Layout of &str:
let mut s1 = String::with_capacity(6);
s1.push_str("hello");
let s2 = s1[..4];
let s3 = "world";
Because of this straightforward layout, Rust efficiently uses &str without extra copying or allocations.
But why exactly can you pass a &String to a function expecting a &str? Let's explore this next.
4. Why Can We Pass &String to Functions Expecting &str?
You can pass a &String to any function expecting a &str because String implements the Deref trait, targeting str. When Rust sees that you're using a &String in a place where a &str is required, it automatically and transparently calls deref to perform the conversion. Importantly, this is a zero-cost operation—no copying, no allocation, and no additional CPU instructions.
How is Deref implemented for String?
Here's the actual simplified Rust implementation:
impl Deref for String {
type Target = str;
fn deref(&self) -> &Self::Target {
// SAFETY: String guarantees valid UTF-8 internally.
unsafe { std::str::from_utf8_unchecked(&self.as_bytes()) }
}
}
What this does is simply reinterpret the bytes inside a String as a &str. The underlying data doesn't move, change, or get copied.
Why is this truly zero-cost?
String already stores a pointer to its data and its length. When you convert to &str, Rust simply reuses this pointer and length directly.
No runtime overhead: Rust's compiler (and LLVM behind it) optimizes away this "conversion" entirely. The memory layout remains identical, so there's literally zero additional cost.
You can verify this yourself by printing memory addresses:
fn main() {
let s = String::from("hello");
let slice: &str = &s;
println!("String data address: {:p}", s.as_ptr());
println!("&str data address: {:p}", slice.as_ptr());
// Both addresses will match, confirming no copying occurred.
}
Best practice: Using &str in function signatures
Even though you can use &String, you generally shouldn't. Prefer &str to ensure your functions are more versatile, handling both String and string literals seamlessly.
// Recommended
fn greet(name: &str) {
println!("Hello, {}!", name);
}
// Less flexible (avoid)
fn greet_string(name: &String) {
println!("Hello, {}!", name);
}
By consistently following this best practice, your Rust code becomes cleaner and more flexible.
* Rust's separation of String and str is carefully designed for speed, memory safety, and ease of use without hidden costs.
If you enjoyed this post or found it helpful, I'm Cuong, and I regularly write about Rust and other interesting programming topics. I'd love to connect with you—feel free to reach out on X, LinkedIn, or subscribe to my blog for more insights!
I think you should better
```
pub fn greet<S: AsRef<str>>(text: S) {
let name = test.as_ref();
println!("Hello, {}!", name);
}
```