Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 008 / 100

String vs &str: a complete Rust guide for production teams

String vs &str in Rust: owned growable text versus borrowed string slices, and which one your functions should accept.

Expona Admin8 min read

Understanding the divide

Rust's string handling forces you to make a choice early on: do you need ownership of the text, or are you borrowing it? In production systems handling user input, API responses, and configuration files, this distinction matters. String vs &str: a complete Rust guide for production teams addresses the most common source of confusion for developers moving from languages like Python or JavaScript where strings are just strings. Let's walk through when and why you'd use each, and how to write APIs that your team will actually want to use.

String is owned and growable

A String in Rust is a heap-allocated, owned data structure that can grow or shrink. Think of it as your string data's primary owner—you hold the deed, you're responsible for cleanup when it goes out of scope, and you can modify it freely.

When you write let name = String::from("Alice"), you're allocating heap memory, storing the text there, and keeping a pointer on the stack. If you pass that String to a function without borrowing, you lose ownership (the function becomes the new owner).

rust
fn main() {
    let mut message = String::from("Hello");
    message.push_str(", World!");
    println!("{}", message); // "Hello, World!"
    
    // Can also create with to_string()
    let greeting = "Rust".to_string();
    
    // Or use format! macro
    let formatted = format!("{} program", greeting);
}

In production, you'll often construct a String when:

  • Reading from a file or socket—you don't know the length in advance
  • Building a response body or log line incrementally
  • Parsing JSON, YAML, or other text that needs modification
  • Accepting user input that will be stored or transformed

The cost is real: heap allocation, potential reallocation as the string grows, and the overhead of tracking ownership. In a request handler processing thousands of messages per second, creating unnecessary Strings adds up.

&str is a borrowed string slice

A &str (pronounced "string slice") is a borrowed reference to a sequence of UTF-8 bytes. It has no ownership and no ability to grow. It points to existing data—either a string literal in the binary, or data owned by something else.

When you write let name = "Alice", you're working with a &str pointing to the read-only data segment. When you borrow a String as &my_string, you're creating a &str over its contents.

rust
fn main() {
    // String literal is &str
    let static_str: &str = "Hello";
    
    // Owned String
    let owned = String::from("World");
    
    // Borrow it as &str
    let borrowed: &str = &owned;
    
    // Both can be printed the same way
    println!("{} {}", static_str, borrowed);
}

&str is cheap: it's just two values on the stack (pointer and length). It's also safe—Rust's borrow checker ensures the data it points to outlives the reference. You can have many &str references to the same data, and since it's immutable, there's no risk of data races.

Use &str when you're reading or examining text without modifying it—which covers the vast majority of function calls in real systems.

Accept &str in function parameters

This is the golden rule of production Rust: functions should accept &str, not String. Let's see why with a real-world example.

Imagine you're building a CRM system and you need a function to validate an email address.

rust
// ❌ Discourages callers with String to use it
fn validate_email_bad(email: String) -> bool {
    email.contains('@')
}

// ✅ Works with both &str and &String, zero friction
fn validate_email(email: &str) -> bool {
    email.contains('@')
}

fn main() {
    // Literal &str
    assert!(validate_email("user@example.com"));
    
    // Owned String—automatically coerces to &str
    let user_email = String::from("alice@example.com");
    assert!(validate_email(&user_email));
    
    // With the bad version, you'd need to call .as_str() every time
    // or move the String into the function (losing ownership)
}

The reason: Rust has deref coercion. When a function expects &str, you can pass &String and it automatically becomes &str. The same goes for slices—&[T] parameters accept both &T and &Vec.

If your function takes String instead, callers with a &str must call to_string(), allocating unnecessarily. Callers with a String lose ownership. It's friction.

In a production API handler, you'd write:

rust
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
use serde::Deserialize;

#[derive(Deserialize)]
struct LoginRequest {
    email: String,
    password: String,
}

async fn login(Json(req): Json) -> impl IntoResponse {
    // Take references—validate_email doesn't need ownership
    if !validate_email(&req.email) {
        return (StatusCode::BAD_REQUEST, "Invalid email").into_response();
    }
    
    // Same here
    if !is_strong_password(&req.password) {
        return (StatusCode::BAD_REQUEST, "Weak password").into_response();
    }
    
    // ... continue with business logic
    (StatusCode::OK, "Logged in").into_response()
}

fn validate_email(email: &str) -> bool {
    email.contains('@') && email.contains('.')
}

fn is_strong_password(password: &str) -> bool {
    password.len() >= 8
}

Notice: the request body deserializes into String (it owns the data from the HTTP body), but every validation function takes &str. This is the pattern. The rule simplifies team code reviews: "Function parameters are &str unless you have a strong reason."

Convert with to_string or as_str

Sometimes you do need to convert. The two main directions are String → &str and &str → String.

&str to String

If you have a &str and need to own the data, use to_string(), to_owned(), or String::from(). They're equivalent; to_string() is idiomatic.

rust
fn main() {
    let name: &str = "Bob";
    
    // All three allocate and own the data
    let owned1 = name.to_string();
    let owned2 = name.to_owned();
    let owned3 = String::from(name);
    
    println!("{} {} {}", owned1, owned2, owned3);
}

Use this when you need to store the string in a struct, pass it across thread boundaries, or hold it longer than the borrowed reference would be valid.

String to &str

If you have a String and need a &str, use &string (borrow) or string.as_str(). Borrowing is simpler and more common.

rust
fn main() {
    let owned = String::from("Carol");
    
    // These do the same thing
    let borrowed1: &str = &owned;
    let borrowed2: &str = owned.as_str();
    
    println!("{} {}", borrowed1, borrowed2);
}

In production code, you almost never need explicit as_str()—just borrow with &. The exception is when type inference needs a hint, or you're reading code that benefits from explicitness.

Here's a pattern we see often in e-commerce order processing:

rust
struct Order {
    id: String,
    status: String,
}

impl Order {
    fn get_status(&self) -> &str {
        // Borrow the status—caller gets &str, lightweight
        &self.status
    }
    
    fn set_status(&mut self, new_status: &str) {
        // Accept &str, own it internally with to_string
        self.status = new_status.to_string();
    }
}

fn main() {
    let mut order = Order {
        id: String::from("ORDER-123"),
        status: String::from("pending"),
    };
    
    let current = order.get_status();
    println!("Status: {}", current); // &str, zero cost
    
    order.set_status("shipped");
}

Common mistakes

Taking String when you mean &str

The most frequent mistake we see in code reviews. A function that only reads the string should take &str. If you see a validation function, formatter, or logger taking String, that's a red flag.

rust
// ❌ Common in junior code
fn log_message(msg: String) {
    println!("[LOG] {}", msg);
}

// ✅ What it should be
fn log_message(msg: &str) {
    println!("[LOG] {}", msg);
}

Unnecessary allocations in loops

Calling to_string() on a literal inside a loop is wasteful.

rust
// ❌ Allocates on every iteration
for user_id in user_ids {
    process(&format!("user_{}", user_id).to_string());
}

// ✅ Use &str directly
for user_id in user_ids {
    process(&format!("user_{}", user_id));
}

// ✅ Or better, if the string doesn't need to be allocated
fn process(key: &str) { }

Cloning when borrowing works

Don't call .clone() on a String if you just need to borrow it. Cloning is expensive and usually means you haven't thought through ownership.

rust
// ❌ Expensive and unnecessary
fn validate(email: String) -> bool {
    let clone = email.clone();
    clone.contains('@')
}

// ✅ Borrow and reference
fn validate(email: &str) -> bool {
    email.contains('@')
}

Forgetting that &str can come from multiple sources

A &str might be a static literal, part of a String, from a slice, or even from a CStr. The function doesn't care—it just works with the bytes. This flexibility is the whole point.

Key takeaways

  • String is owned, heap-allocated, and growable. Use it when you need to own or mutate text.
  • &str is a borrowed slice: cheap, immutable, and flexible. Use it in function parameters whenever possible.
  • Functions should accept &str, not String. This works with both literals and borrowed Strings via deref coercion.
  • Convert &str to String with to_string() when ownership is needed; go the other way with &string or .as_str().
  • In production systems—especially request handlers, validators, and formatters—the pattern is clear: accept &str, return &str, own String only when the data must live long or change.
  • Avoiding unnecessary String allocations and ownership transfers directly impacts latency and memory in high-throughput services.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles