Posted on ::

Rust has two main string types, and when you're coming from languages with one string type, it's confusing as hell. Here's what I learned building an in-memory key-value store.

String: The owned type

String is heap-allocated and growable. It owns the underlying data and is responsible for cleaning it up when it goes out of scope.

let mut s = String::from("hello");
s.push_str(" world");  // Can modify, it owns the data
// s is automatically freed when it goes out of scope

Use String when you need to create stuff at runtime (user input or request, etc). Or you need to mutate it, or if it needs to live beyond the current scope of the function.

&str: The borrowed type

&str is a string slice - just a view into string data. It's a borrowed type because it doesn't own the data, it's a pointer to the start plus the length.

let s = String::from("hello world");
let slice: &str = &s[0..5];  // "hello", just a view

Since it's borrowed, the compiler won't let you use it after the owner goes out of scope:

fn bad() -> &str {
    let s = String::from("hello");
    &s  // Compiler error: s is dropped, can't return reference
}

Use &str when you are reading or analyzing data without modifying it, or parsing a string.

Why this matters

In my key-value store, I parse commands from TCP requests. The request buffer is temporary (arena-allocated), but stored values need to live permanently.

// Parse from temporary request buffer (&str)
fn parse_command(input: &str) -> Command {
    // input is borrowed, lives in request scope
}

// Store needs owned data (String)
fn set(&mut self, key: String, value: String) {
    self.map.insert(key, value);  // Needs owned data
}

If I tried to store &str references to the request buffer, I'd have dangling pointers after the request ends. The compiler catches this at compile time.

Memory layout

String is 24 bytes on 64-bit systems:

  • Pointer to heap data (8 bytes)
  • Length (8 bytes)
  • Capacity (8 bytes)

&str is 16 bytes:

  • Pointer to data (8 bytes)
  • Length (8 bytes)

&str is lighter because it doesn't track capacity. It can't grow, it's read-only.

Converting them

let owned = String::from("hello");
let borrowed: &str = &owned;  // String -> &str (cheap)

let borrowed = "hello";  // &str (string literal)
let owned = borrowed.to_string();  // &str -> String (allocates)

String literals like "hello" are &str by default, they point to data baked into the binary at compile time.

Takeaway

String = owned, heap-allocated, can modify. Use for data you create and own.

&str = borrowed view, read-only. Use for reading, parsing, function parameters.

The distinction felt annoying at first, but it forces you to think about memory ownership explicitly. After dealing with use-after-free bugs in Zig (where you manually track this in your head), having the compiler enforce it is actually... nice

Table of Contents