Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 4

String vs. &str in Function Signatures

Recommended resources

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:

  1. String: This is an owned, heap-allocated, growable, mutable sequence of UTF-8 encoded text. When a variable of type String owns 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.

  2. &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 a String, 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.

This diagram illustrates how `String` and `&str` relate to memory. The `noodles` variable is a `String`; it lives on the stack and holds a pointer, length, and capacity, "owning" the actual character data on the heap. The `oodles` and `poodles` variables are `&str` slices; they are also on thestack but simply "borrow" a view of data that lives elsewhere—either on the heap (from the `noodles` `String`) or in preallocated read-only memory (for a string literal).

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.

  1. Return String if 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 owned String. The function created this new data, and it must transfer ownership of that data to the caller.

  2. Return &str if 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:

  1. Create a new project with cargo new string_signatures.
  2. Replace the contents of src/main.rs with the code below.
  3. Fill in the /* S */ placeholders with the correct types. Don't worry about implementing the function bodies yet, just focus on the signatures.
  4. 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:

  • String is Owned: Use it when your data needs to be mutable, growable, or when a struct or function needs to own its string data.
  • &str is Borrowed: Use it for immutable views of string data. It's cheap and efficient.
  • Function Arguments: Default to &str for parameters that only need to be read. This makes your function maximally flexible thanks to Deref coercion.
  • Function Returns: Return String when creating new data. Return &str only 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.