String vs &str in Rust: The Complete Guide for 2026

Max WellsMax WellsFounder of Rustify

String vs &str in Rust comes down to one core rule: String owns text, while &str borrows it. If you internalize that rule, most of Rust's string-related compiler errors become much easier to reason about.

For function parameters, &str is usually the right default. For returning or storing new text, String is usually the right choice.

By Max Wells, updated August 2026

TL;DR: String and &str are Rust's two main string types. String is owned, heap-allocated, and growable. &str is a borrowed slice: a read-only view into string bytes that can live in the heap, stack, or static binary memory.

  • String: owned, heap-allocated, growable; like Vec<u8> with UTF-8 guarantee
  • &str: borrowed string slice; read-only view into any string data
  • Function params: always prefer &str over &String; it accepts more input types
  • Returning strings: return String when you need to transfer ownership or build new data
  • Converting: "hello".to_string()String; my_string.as_str() or &my_string&str

Who Should Read This?

This article is written for developers who have started learning Rust and keep running into confusing compiler errors around strings; the dreaded "expected String, found &str" or "cannot return reference to local variable." If you are coming from Python, JavaScript, or Go, where strings are a single concept, Rust's two-type string system will feel like friction at first. This guide explains not just the rules but the reasoning behind them, so the mental model sticks. Backend engineers interviewing for Rust roles at US companies, where senior positions pay $170K–$220K, are expected to explain this distinction clearly in technical screens. Understanding String vs &str is a foundational signal of Rust proficiency.

Bottom line: Best default for reading string input: &str. Best default for owned or newly built text: String.


What Is the Core Difference Between String and &str?

String owns its data and can grow; &str is a read-only borrowed view into existing string bytes that owns nothing.

This distinction is a direct expression of Rust's ownership model. When you create a String, you allocate memory on the heap and take full ownership of it; including responsibility for freeing it. When you use &str, you borrow a view into bytes that live somewhere else: inside a String, in static binary memory, or on the stack.

// String: owned, heap-allocated, growable
let owned: String = String::from("hello world");
 
// &str: borrowed view into the String's heap data
let borrowed: &str = &owned;
 
// &str pointing to static memory in the binary; no heap involved
let static_ref: &str = "hello world";

The practical consequence: you cannot return a &str pointing to a local String you created inside a function. When the String is dropped at the end of the function, the view becomes invalid. Rust's borrow checker catches this at compile time; no runtime crashes.


When Should You Use String vs &str in Function Parameters?

Always prefer &str for function parameters that only read string data; it accepts both String and &str inputs without cloning.

This is the single most important practical rule for Rust strings:

// ❌ Too restrictive: only accepts &String, not &str literals
fn greet(name: &String) {
    println!("Hello, {name}!");
}
 
// ✅ Accepts String, &str, and anything that derefs to str
fn greet(name: &str) {
    println!("Hello, {name}!");
}
 
// Both of these now work with the &str version:
greet("Alice");                    // &str literal; no allocation
greet(&String::from("Bob"));       // &String coerces to &str automatically

This works because of Rust's deref coercion: &String automatically coerces to &str. The reverse is not true; &str does not coerce to &String. The more general type always wins for parameter types.

The exception: when the function needs to take ownership (to store the string in a struct, for instance): then accept String and let the caller decide whether to clone or move.

Bottom line: Always use &str for function parameters that only read strings; it accepts String, &str, string literals, and any type that derefs to str, with zero allocation cost. Using &String in a function signature is always unnecessarily restrictive.


When Do You Need to Return String vs &str From a Function?

Return String when you build or transform string data inside the function; return &str only when you return a reference into data that will outlive the function.

// ✅ Returning &str: valid because "Rust" lives in static memory forever
fn get_language_name() -> &'static str {
    "Rust"
}
 
// ✅ Returning &str: valid because the input lives long enough
fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[0..i],
        None => s,
    }
}
 
// ✅ Returning String: when you build new data
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}
 
// ❌ Won't compile: returning reference to local data
fn broken() -> &str {
    let s = String::from("local data");
    &s  // ERROR: s is dropped at end of function, reference would dangle
}

The first_word example is the canonical illustration of lifetime inference: the compiler figures out that the returned &str borrows from the input s, so the reference is valid as long as s is valid. This happens automatically; no explicit lifetime annotation required for this pattern.


How Do You Convert Between String and &str?

Conversions in both directions are built into the standard library; from &str to String always copies data; from String to &str is always free.

ConversionCodeCost
&strString"hello".to_string()Heap allocation (copies bytes)
&strStringString::from("hello")Same; heap allocation
&strString"hello".to_owned()Same; heap allocation
String&strmy_string.as_str()Free; returns a view
String&str&my_stringFree; deref coercion
String&str&my_string[..]Free; explicit full slice

Going from &str to String always involves a heap allocation and byte copy; the owned type needs its own memory. Going from String to &str is always free; it just creates a fat pointer (pointer + length) into the existing heap buffer.

All three &strString methods are equivalent. Style conventions in the Rust community: "literal".to_string() for string literals inline, String::from(variable) when the intent is type conversion from a variable, and .to_owned() when the context emphasizes ownership transfer over type conversion.


3 spots open this month → Check if you are eligible.

We help experienced developers transition into Rust roles at €80K–€150K+ in Europe or $130K–$200K+ in the US.

How Does Memory Layout Differ Between String and &str?

String holds three values on the stack: pointer, length, and capacity. &str holds two: pointer and length. Neither stores the actual bytes on the stack.

String (on stack: 24 bytes on 64-bit):
┌──────────┬──────────┬──────────┐
│ ptr      │ length   │ capacity │
└──────────┴──────────┴──────────┘

     ▼ heap
┌──────────────────────────────────┐
│ h  e  l  l  o     w  o  r  l  d │
└──────────────────────────────────┘
 
&str (fat pointer: 16 bytes on 64-bit):
┌──────────┬──────────┐
│ ptr      │ length   │
└──────────┴──────────┘

     ▼ (heap, stack, or static binary memory)

The capacity field in String is what enables growth without reallocating on every write. When you push_str() onto a String, Rust checks if capacity > length; if so, it writes in place; if not, it reallocates with roughly double the capacity.

&str has no capacity because it cannot grow; it's a read-only view.

Understanding the memory layout helps reason about performance. Moving a String across a function call copies 24 bytes (the stack header), not the heap data; the heap data stays in place and the pointer follows. Moving a &str copies 16 bytes. For strings of any non-trivial length, both operations are O(1): the bytes themselves do not move.


What Is the Difference Between &str, &String, and str?

str is the unsized primitive; &str is a borrowed reference to it; String is the owned version. You almost always use &str or String; never bare str.

The type str without the & is an unsized type; the compiler doesn't know its size at compile time, so you can't hold it directly on the stack. You always work with it behind a pointer: &str, Box<str>, or Rc<str>.

let a: &str = "hello";                // Most common
let b: Box<str> = Box::from("hello"); // Uncommon: fixed-size owned slice
let c: std::rc::Rc<str> = "hello".into(); // Shared ownership of string slice
// let d: str = ...;  // Won't compile: str is unsized

The &String type is a reference to a String struct (which contains pointer + length + capacity). &str is a fat pointer directly into the bytes. Since &String auto-derefs to &str, there is almost never a reason to use &String in a function signature.

Box<str> is occasionally useful when you need an owned, immutable, fixed-size string that doesn't need to grow. It saves one field (capacity) compared to String; useful in memory-constrained situations or when building large collections of strings where the capacity overhead adds up.


What Are the Most Common String Mistakes in Rust?

The three most common errors are using &String in function signatures, unnecessary cloning, and fighting the borrow checker when returning string references.

Mistake 1: &String in function parameters

// ❌ Unnecessarily restrictive
fn print_name(name: &String) { println!("{}", name); }
 
// ✅ Prefer &str: accepts everything
fn print_name(name: &str) { println!("{}", name); }

Mistake 2: Cloning when borrowing works

fn process(s: String) { /* stores or owns s */ }
 
let my_string = String::from("data");
process(my_string.clone()); // ❌ wastes allocation if you still need my_string
process(my_string);         // ✅ move if you don't need it anymore
 
fn read_only(s: &str) { /* only reads */ }
read_only(&my_string);      // ✅ borrow; zero cost

Mistake 3: Storing &str in structs without thinking

// Works but requires lifetime annotations everywhere
struct Config<'a> {
    name: &'a str,
}
 
// Simpler for most cases: just own the data
struct Config {
    name: String,
}

Use &str in structs when you have a strong reason (e.g., zero-copy parsing from a long-lived buffer). For typical application code, String in structs is the right default.


How Do String Types Interact With Async Code?

In async Rust, string lifetimes interact with Send bounds and task spawning; understanding this prevents a common class of compile errors in Tokio applications.

When you call tokio::spawn, the future must be 'static + Send. A &str with a non-'static lifetime is not 'static, so it cannot be sent into a spawned task:

// ❌ Won't compile: &str borrows from local scope
let name = String::from("Alice");
tokio::spawn(async move {
    // If you try to pass &name into here, it won't be 'static
    process(&name).await;
});
 
// ✅ Clone or move the String into the task
let name = String::from("Alice");
tokio::spawn(async move {
    // name is moved into the closure: now 'static for the task's lifetime
    process(&name).await;
});

The practical rule for async Rust: use String for any data that crosses await points, gets stored in a struct used across tasks, or is sent into spawned tasks. Use &str only within the synchronous scope of a single function or closure where the lifetime is clear. This rule eliminates most lifetime-related compile errors in async Rust codebases.

Bottom line: In async Rust with tokio::spawn, use String (owned) for any data that crosses task boundaries; &str with a non-'static lifetime will not compile inside spawn. Within a single async fn, you can use &str freely between await points.


What Common Mistakes Do Rust Beginners Make With String Types?

String-related mistakes are the most common source of compile errors and unnecessary cloning for developers new to Rust; each has a clear, idiomatic fix.

  • Accepting &String in function parameters instead of &str. This is probably the single most common beginner mistake. Every Rust linter and code review will flag it. The fix is always &str for read-only access. The compiler itself often suggests this change in the error message. The habit to build: whenever you write a function that reads but does not own a string, the parameter type should be &str.

  • Cloning to resolve borrow checker errors without understanding why. Adding .clone() everywhere is a common coping strategy when the borrow checker rejects code. Most clone calls around strings are unnecessary: the real fix is either restructuring the borrows, moving the value, or switching the function signature to take &str. Excessive cloning in string-heavy code shows up clearly in profiling as heap allocation pressure.

  • Returning a &str created from a local String. This is the "dangling reference" pattern the borrow checker catches. The attempted workaround is often &format!("...") or returning &local_string.as_str(). Neither works. The correct fix is to return String and let the caller decide what to do with it. This is not a limitation: it is correct; you cannot safely return a reference to memory that will be freed when the function returns.

  • Storing &str in structs without understanding lifetime annotations. Beginners sometimes store &str in structs to avoid cloning, then encounter lifetime annotation requirements and add 'a annotations everywhere without understanding what they mean. Unless you are building a zero-copy parser or working in an embedded context, storing String in structs is the correct default. The cognitive overhead of 'a annotations rarely pays off for application code.

  • Using to_string() on &str to pass to a function that already accepts &str. This allocates unnecessarily. If the function signature is fn f(s: &str), you can pass "literal" directly: no conversion needed. Unnecessary to_string() calls are easy to spot with clippy and are a signal of confusion about when conversion is actually required.

  • Confusing format! return type with &str. format!("hello {}", name) returns a String, not a &str. The string is built at runtime into a new heap allocation. This surprises developers who expect it to be a &str since string literals are &str. The rule: if the string is computed at runtime (via format!, concatenation, or any dynamic construction), it must be String.


How Do You Stop Getting String and &str Errors?

If you want to stop fighting the borrow checker and start writing idiomatic Rust with confidence, Rustify's 9-week bootcamp covers ownership, borrowing, lifetimes, and the string type system with dedicated exercises and 1:1 coaching from engineers who use Rust in production. The bootcamp is designed for developers making the transition from dynamic languages into systems programming, and it addresses exactly the mental model gaps that make Rust's string types confusing at first.


Frequently Asked Questions

Almost always String. Storing &str in a struct requires lifetime annotations on the struct; correct, but adds complexity throughout your codebase. Use String for owned data, and reach for &str struct fields only when you are doing zero-copy parsing or have a specific performance need that profiling confirms is meaningful. The added complexity of lifetime-annotated structs is rarely worth the allocation savings in typical application code. For library code that processes large volumes of string data, zero-copy patterns with &str fields can be justified; but measure first.

The two-type design reflects the ownership model. A single string type would mean always allocating (like Java's String) or always requiring explicit lifetime annotations everywhere. Having both allows Rust to be zero-cost: borrow for free when you only need to read, allocate only when you need ownership. The distinction also makes it impossible to accidentally invalidate a string reference; the type system enforces validity at compile time. Languages with a single string type silently copy or reference-count strings, hiding the cost; Rust makes the cost visible and controllable.

Using &str where String would allocate is faster; you avoid the heap allocation and copy. But once data is allocated, iterating or reading a &str vs String content is identical; both are just bytes in memory, accessed through a pointer. The performance difference only exists at the point of creation. In a hot loop processing millions of strings, eliminating allocations via &str can make a 5–10x throughput difference. In a web handler that processes one request at a time, the difference is immeasurable.

Cow<str> (Clone-on-Write) holds either Borrowed(&str) or Owned(String) and clones lazily only when mutation is needed. Use it in library code where you sometimes return a borrowed view and sometimes need to allocate; it avoids unnecessary cloning in the common borrowed case. A typical use case: a function that normalizes a string; if the input is already normalized, return a Borrowed view with no allocation; if normalization changes the content, return an Owned String. Cow<str> is a library-author tool; most application code doesn't need it.

With care. tokio::spawn requires 'static bounds, so &str with a non-'static lifetime cannot be sent into a spawned task. The safe default in async Rust: use String for data that needs to cross await points or be sent between tasks. Within a single async fn, you can use &str freely as long as the borrow doesn't cross an await where the borrowed data might be moved or dropped. The compiler will catch violations; but understanding the rule upfront avoids the confusion.

Rust's String and &str are guaranteed to contain valid UTF-8. If you receive bytes from a network or file that might not be UTF-8, use String::from_utf8(bytes) (returns Result) or String::from_utf8_lossy(bytes) (replaces invalid bytes with the replacement character). For working with raw bytes without a UTF-8 guarantee, use Vec<u8> (owned) or &[u8] (borrowed slice): the byte equivalents of String and &str. The UTF-8 guarantee in String means len() returns byte count, not character count; use .chars().count() for Unicode character count.


Keep Reading

Sources

Ready to Land a $80-120k Rust Job?