Welcome to the next lesson in our journey through Rust's ownership system. In the previous session, we tackled the rules around mutable and immutable references, learning how to structure code to avoid borrow checker errors by separating read and write operations.
Today, we'll focus these concepts on one of the most common data types you'll encounter: strings. You've likely seen both String and &str and wondered about the difference. This lesson will demystify them, clarifying their distinct roles based on ownership. Our goal is to equip you to make deliberate, idiomatic choices between String and &str in your function signatures, which is a cornerstone of writing flexible and performant Rust APIs.
Owned Strings and Borrowed Slices
At the heart of Rust's string handling are two primary types, each serving a different purpose related to ownership:
-
String: This is an owned, heap-allocated, growable, mutable sequence of UTF-8 encoded text. When a variable of typeStringowns the data, it is responsible for allocating memory to hold the string's contents and deallocating that memory when it goes out of scope. Think of it as the "owner" of the string content. -
&str(a "string slice" or "string reference"): This is a borrowed, immutable view into a sequence of UTF-8 text. It does not own the data it points to. Instead, it's a "slice" that refers to data owned by something else—either aString, or text embedded directly in your program's binary (a string literal like"hello").
The distinction between owning data and borrowing a view of it is the most critical concept to grasp. Your experience with Python's garbage collector abstracts this away, but in Rust, this control is given directly to you through the type system.
The following diagram provides an excellent visual model of this relationship.

Let's dig into the details of these memory layouts and what they mean for performance.
String vs str in Rust: The Only Guide You'll Ever Need | by Cuong Le
This article provides a clear breakdown of the two types and their internal representation.
Read from the beginning down to the end of section 3, "Diving Deeper: Memory Layout". Focus on these parts: The definitions of String and &str. The discussion and diagrams under Memory Layout, which explain what each type stores. Notice how &str is just a pointer and a length, making it very cheap to pass around.
The key takeaway is that &str provides an efficient, read-only view of data without needing to copy it. As the video you're about to see explains, operations like trimming whitespace can be zero-allocation operations for this very reason.
Understanding Strings in Rust - String vs &str
The video "Understanding Strings in Rust" by Federico Terzi has a great segment demonstrating the power of string slices.
Watch the part from this timestamp where the .trim() operation is explained. The "magic" here is that trim doesn't create a new string on the heap; it returns a new &str that is simply a different "view" of the original String's data. This is a perfect example of a zero-cost abstraction.
The Golden Rule for Function Arguments: Accept &str
Now, let's apply this to function design. When a function only needs to read string data, which type should it accept as a parameter: &String or &str?
Consider these two function signatures:
fn process_log_inefficient(log: &String) {
println!("Log message: {}", log);
}
fn process_log_idiomatic(log: &str) {
println!("Log message: {}", log);
}
The idiomatic choice is almost always &str. The reason is flexibility. A function that takes &str can be called with:
- A string literal:
process_log_idiomatic("operation successful"); - A reference to an owned
String:let log_entry = String::from("high CPU usage"); process_log_idiomatic(&log_entry); - A slice of a
String:process_log_idiomatic(&log_entry[0..4]);
In contrast, the process_log_inefficient function can only accept a &String. If you have a string literal, you would be forced to allocate a new String first, just to call the function: process_log_inefficient(&"operation successful".to_string());. This is inefficient and clunky.
How does Rust make this work so seamlessly? The magic is a feature called Deref Coercion. Rust can automatically convert a &String into a &str whenever it's needed. This is a zero-cost conversion because a String already contains the pointer and length needed to create a &str.
String vs str in Rust: The Only Guide You'll Ever Need | by Cuong Le
Let's return to the same article to understand this "magic" and solidify the best practice.
Read the final two sections of the article: Why it works, which explains the Deref trait. The best practice, which summarizes the rule for function arguments.
So, the general rule for function inputs is: If you only need to read the string data, always accept &str.
There are, of course, exceptions. You would accept String if you need to take ownership, or &mut String if you need to modify the string in place. The following video provides a concise mental model for making this decision.
If you still struggle with String and &str in Rust, watch this
In his video, Oliver Jumpertz provides a great decision-making framework for function arguments.
Watch the segment from Function arguments. Pay attention to the questions he suggests asking: "Do we need ownership?" and "Do we need to mutate?". This flowchart thinking—starting with &str and only using &mut String or String when necessary—is the path to idiomatic Rust.
Function Return Types: Who Owns the Output?
When returning a string from a function, the decision again comes down to ownership.
-
Return
Stringif you are creating new string data. If your function builds a string from scratch, modifies it (like changing case), or concatenates multiple strings, it must return an ownedString. The function created this new data, and it must transfer ownership of that data to the caller. -
Return
&strif you are returning a slice of an input string. If your function is simply extracting a part of a string that was passed into it, you can return a&str. This is a borrow, which is highly efficient as it avoids allocation. However, this creates a dependency: the returned slice cannot outlive the original string it was borrowed from. This dependency is managed with lifetimes, a topic we will cover in the next lesson.
Steve Klabnik, a prominent member of the Rust community, provides clear examples of this distinction.
When should I use String vs &str?
This blog post by Steve Klabnik offers excellent, practical rules of thumb.
Read the sections titled Level 2 and Level 3. The "Level 2" rule summarizes what we've just discussed for parameters. In "Level 3", compare the first_word function (which can return &str) with first_word_uppercase (which must return String). The error message shown for trying to return a local reference from first_word_uppercase is a classic example that you will surely encounter.
Your Turn: Design the Function Signatures
Let's apply these principles. Below is a skeleton for a small utility that processes hostnames, a task relevant to the networking tools you're interested in building. Your task is to fill in the /* S */ placeholders with the most appropriate string type (String, &str, &mut String) for each function parameter and return value.
Instructions:
- Create a new project with
cargo new string_signatures. - Replace the contents of
src/main.rswith the code below. - Fill in the
/* S */placeholders with the correct types. Don't worry about implementing the function bodies yet, just focus on the signatures. - One function will give you a compiler error about "missing lifetime specifier." This is expected! Make a note of which one it is and why you think the compiler is asking for more information.
// Don't change the main function!
fn main() {
let hostname = String::from("server01");
let domain = "local";
// This function should take ownership of hostname and append the domain.
let fqdn = create_fqdn(hostname, domain);
println!("Created FQDN: {}", fqdn);
// This function should just print the FQDN.
log_hostname(&fqdn);
// This function should find the service part of a hostname like "api-server01".
// It should work for both &String and &str.
let full_name = "api-server01.local";
if let Some(service) = get_service_name(full_name) {
println!("Service detected: {}", service);
}
}
/// Takes ownership of a hostname and returns a new String with the domain appended.
/// e.g., "server01", "local" -> "server01.local"
fn create_fqdn(hostname: /* S */, domain: /* S */) -> /* S */ {
// implementation would go here
unimplemented!();
}
/// Prints a hostname to the console. Should not take ownership.
fn log_hostname(hostname: /* S */) {
// implementation would go here
unimplemented!();
}
/// Returns the part of a hostname before the first '-', if any.
/// e.g., "api-server01" -> "api"
/// This should be a zero-copy operation.
fn get_service_name(hostname: /* S */) -> Option</* S */> {
// implementation would go here
unimplemented!();
}
Think carefully about ownership for each function. Is it creating new data? Is it just reading existing data? Is it returning a piece of its input?
Conclusion
Mastering the distinction between String and &str is a significant step toward writing idiomatic Rust. It moves you from simply making the code compile to designing APIs that are both flexible and performant.
Key Takeaways:
Stringis Owned: Use it when your data needs to be mutable, growable, or when a struct or function needs to own its string data.&stris Borrowed: Use it for immutable views of string data. It's cheap and efficient.- Function Arguments: Default to
&strfor parameters that only need to be read. This makes your function maximally flexible thanks toDerefcoercion. - Function Returns: Return
Stringwhen creating new data. Return&stronly when returning a slice of an input argument.
In the exercise, you likely saw that the get_service_name function, which should return a slice of its input, caused a compiler error. The compiler correctly identified that if you return a borrow (&str), it needs to know how long that borrow is valid. This is precisely what lifetimes are for. In our next lesson, we will learn how to add lifetime annotations to functions like this, fully unlocking the power of zero-copy return values.