DEV Community

Manish Sah
Manish Sah

Posted on

Rust Strings Demystified: Literals, Slices, and Fat Pointers Under the Hood

When developers switch to Rust from high-level languages like Python or JavaScript, one of the first mental speedbumps is the string system.

Why are there two main string types (String vs &str)? What actually happens when a function returns "Yummy!"? And why is a string slice called a "fat pointer"?

In this post we'll look under the hood at how Rust lays out string literals, string slices, and pointers in memory — and why this design makes Rust strings fast and memory-efficient.


1. Literal vs. Slice

Two terms get conflated a lot, so let's separate them:

  • Literal: text hardcoded directly into your .rs source file (e.g., "Hello, world!").
  • Slice (&str): a view into a contiguous run of valid UTF-8 bytes somewhere in memory, without owning them.

The relationship between the two:

Every string literal is a string slice, but not every string slice is a string literal.

Example

fn main() {
    // 1. A string literal — embedded in the compiled binary.
    // Type: &'static str
    let literal: &str = "Hello, world!";

    // 2. A dynamic String — allocated at runtime on the heap.
    let mut user_input = String::new();
    user_input.push_str("Rustacean");

    // 3. A slice into that heap string.
    // Type: &str (points into user_input's heap buffer, not the binary)
    let dynamic_slice: &str = &user_input[0..4]; // "Rust"

    // Both are &str, so the same function accepts either.
    print_slice(literal);       // "Hello, world!"
    print_slice(dynamic_slice); // "Rust"
}

fn print_slice(slice: &str) {
    println!("Printing slice: {}", slice);
}
Enter fullscreen mode Exit fullscreen mode
  • literal is a string literal, so its type is &'static str — it's embedded directly in the binary.
  • dynamic_slice is a valid &str, but not a literal — it was carved out of user_input at runtime and didn't exist in your source code.

2. Normal Pointer vs. Fat Pointer

In C, a string pointer is a plain 8-byte address (on 64-bit). String functions scan forward byte-by-byte until they hit a null terminator (\0).

Rust drops null terminators entirely. Instead, &str is a fat pointer: two 64-bit fields packed back to back, 16 bytes total on a 64-bit target.

┌─────────────────────────┬─────────────────────────┐
│     Pointer (8 bytes)   │     Length (8 bytes)    │
├─────────────────────────┼─────────────────────────┤
│  0x00007FFF00401050     │            6             │
└─────────────────────────┴─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  1. Pointer: the address of the first byte.
  2. Length: the number of bytes in the slice.

Note: this ptr-then-length picture is the right mental model, but Rust doesn't guarantee that exact field ordering as part of its stable ABI. What is guaranteed is that a &str/&[T] reference is two machine words wide.


3. What Happens When a Function Returns "Yummy!"?

fn picky_eater(food: &str) -> &str {
    if food == "strawberry" {
        "Yummy!"
    } else if food == "potato" {
        "I guess I can eat that."
    } else {
        "No thanks!"
    }
}
Enter fullscreen mode Exit fullscreen mode

Step A: The read-only data segment (.rodata)

At compile time, string literals like "Yummy!" get written into the binary's read-only data segment (.rodata). When the OS loads the binary, that data lands at a fixed address in RAM for the entire life of the program — which is exactly why literals get the 'static lifetime.

Say the OS loads "Yummy!" at 0x00007FFF00401050:

Address Byte (hex) Character
0x00007FFF00401050 0x59 'Y'
0x00007FFF00401051 0x75 'u'
0x00007FFF00401052 0x6D 'm'
0x00007FFF00401053 0x6D 'm'
0x00007FFF00401054 0x79 'y'
0x00007FFF00401055 0x21 '!'

Step B: Constructing and returning the fat pointer

Rust doesn't copy those 6 bytes onto the stack. It just constructs a 16-byte fat pointer:

  • Pointer = 0x00007FFF00401050
  • Length = 6

Since the underlying bytes live in .rodata for the program's entire lifetime, returning this pointer is completely safe — you're never pointing at stack memory that disappears when the function returns.


4. Zero-Cost Slicing (and its one sharp edge)

Because length is metadata on the pointer itself, sub-slicing is zero-cost:

let full: &str = "Yummy!";
let sub: &str = &full[1..5]; // "ummy"
Enter fullscreen mode Exit fullscreen mode

No new allocation, no copy — just a new 16-byte fat pointer:

Memory:     [  'Y'  |  'u'  |  'm'  |  'm'  |  'y'  |  '!'  ]
Address:     ...1050  ...1051  ...1052  ...1053  ...1054  ...1055
             ▲        ▲                             ▲
             │        └──────────────┬──────────────┘
             │                       │
`full`:  ptr = ...1050, len = 6      │
`sub`:   ptr = ...1051, len = 4 ─────┘
Enter fullscreen mode Exit fullscreen mode
  • full: pointer = 0x...1050, length = 6 ("Yummy!")
  • sub: pointer = 0x...1051 (offset by 1 byte), length = 4 ("ummy")

Both slices point into the exact same memory.

The gotcha: those slice indices are byte offsets, and Rust requires them to land on UTF-8 character boundaries. &full[1..5] works because every character in "Yummy!" is a single ASCII byte. Slice a multi-byte character (say, an emoji or accented letter) in the middle of its encoding, and Rust panics at runtime rather than handing back corrupted UTF-8. Coming from Python, where str[1:5] just silently does the "wrong" thing on multi-byte content, this is worth internalizing early.


5. Summary Cheat Sheet

Concept Definition Lifetime / Memory Size
Literal Text hardcoded in source ("..."). 'static, lives in .rodata. Embedded in the binary.
Slice (&str) A view (pointer + length) into valid UTF-8 bytes. Depends on what it borrows. 16 bytes (fat pointer).
String Owned, growable, heap-allocated UTF-8 buffer. RAII — dropped when it goes out of scope. 24 bytes (pointer, length, and capacity fields).

Why this design matters

  1. O(1) length lookups.len() costs nothing at runtime; the length is already sitting in the fat pointer.
  2. Zero-copy passing — handing a 1 GB string slice to a function copies 16 bytes of pointer metadata, not the string.

Top comments (0)