← all lessons
Ownership & Borrowing · #6 of 13

Slices and the Two Kinds of String

Why Rust splits owning a buffer from borrowing a view into it

The first function you write that takes “a string” forces a choice no other language makes you make out loud: do you want to own the text, or just look at it? Rust gives you two different types for those two different jobs — and once you see why, half the standard library suddenly rhymes.

The uncomfortable moment comes early: you type fn greet(name: and pause. String? &String? &str? They all sound like “a string.” Almost always the right answer is the last one — &str, the borrowed view — but to know why, you need to see that text and lists in Rust each come in two layers: an owning type that holds the bytes, and a borrowed view (called a slice) into part of them.

A few more words you’ll need

The idea: a slice is a pointer plus a length

A slice is a borrowed view into a contiguous run of values. Internally it is just two numbers: a pointer to where the values start, and a length — how many to look at from there. It owns nothing. The memory belongs to someone else; the slice is a window onto it, and the borrow rules from lesson 5 still apply (the data must outlive the window).

THE BUFFER (owned by a String)hello·worldbyte 0a &str sliceptr ●────len 5
A &str is a fat pointer: an address plus a length. This one describes “hello” — the first 5 bytes — without copying a single byte. Change the length to 11 and the same pointer describes the whole string.

Rust splits its core “sequence of stuff” types into an owning form and a borrowing form:

Owns the bufferBorrows a view of it
String&str
Vec<T>&[T]

The rule you’ll internalise after a handful of functions: take slices, return owned values. Slices accept everything (an owned value lends a view of itself for free) and don’t force the caller to allocate. Returning an owned value hands the caller full control over the result.

Build one, then borrow a view

Run this. We grow a String on the heap, then take a &str slice of its first five bytes — no second allocation, just a pointer and a length pointed at bytes the String already owns:

Own the buffer, borrow a window editable · real rustc
Open in Playground ↗ ready

It prints hello, world, then hello, then 12 bytes. The slice syntax &owned[0..5] says “a view into owned from byte 0 up to (not including) byte 5.” You can also write &owned[7..] for “byte 7 to the end”, &owned[..5] for “the start up to 5”, and &owned[..] for “the whole thing.”

One function, both kinds of string

Because an owned String can lend out a &str view of itself automatically, a function that asks for &str accepts both string literals and owned Strings. This is why &str is the right default for parameters:

A &str parameter takes everything editable · real rustc
Open in Playground ↗ ready

&owned is a &String, yet it slots into a &str parameter without a complaint. That silent conversion is called deref coercion — Rust knows a String can always produce a &str, so it inserts the step for you. It happens in a handful of similar owned/borrowed pairs across the language.

The trap: byte indices are not character indices

Here is the rule that surprises everyone. Slice indices into a string are byte offsets, not character counts. In pure ASCII the two line up perfectly, because every character is one byte. The moment a multi-byte character appears, they diverge — and Rust would rather panic than hand you a torn half-character.

First, watch the divergence. The word "héllo" looks like five characters, but the é takes two bytes, so it is six bytes long:

Characters vs bytes: they are not the same count editable · real rustc
Open in Playground ↗ ready

It prints chars: 5 and bytes: 6. .chars() walks Unicode characters; .bytes() walks raw UTF-8 bytes. Because they disagree, “the character at index 1” and “the byte at index 1” are different positions — which is exactly why Rust refuses to let you write s[1] on a string at all. There is no O(1) “nth character”: finding it would mean decoding from the start every time, and Rust will not hide that cost behind innocent-looking square brackets.

Now the panic. This program compiles — the byte range is only checked when it runs — but byte index 2 lands in the middle of the é, so slicing there would produce invalid UTF-8. Rust stops the program instead. Hit Run and read the panic message:

Slicing through a character panics at runtime editable · real rustc
Open in Playground ↗ ready

The output is a runtime panic: byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3 of string). That is not a bug in your slice — it is Rust refusing to give you a half-character that wouldn’t be valid UTF-8. The fix when you want to work with characters is to iterate them explicitly:

fn main() {
    let s = "héllo";
    let first_three: String = s.chars().take(3).collect();
    println!("{first_three}"); // "hél"
}

.chars() yields an iterator over Unicode characters; .take(3).collect() gathers the first three into a fresh String, every character whole. You’ll meet iterators properly in lesson 9.

The same shape for lists

Slices aren’t a string-only idea — str is just the text-flavoured one. The identical pattern works on Vec<T> through &[T]:

One function, Vec or array, doesn't care editable · real rustc
Open in Playground ↗ ready

It prints 10, then 30. sum never asks where the numbers came from — a Vec, a fixed-size array, a slice of a larger buffer. It just walks a contiguous run of i32. That flexibility is the whole payoff of accepting &[T] instead of &Vec<i32>.

Why two types instead of one?

A fair question: if String and &str describe the same text, why not collapse them into one type? Because ownership and borrowing carry different obligations, and the compiler needs to tell them apart.

Merging them would force every “takes a string” function to also decide whether to free, which leads either to memory bugs or to runtime reference counting. Keeping them separate lets the compiler prove, statically, that one party frees and everyone else just looks.

Once you see the shape, it recurs all over the standard library:

The recipe never changes: an owning type that allocates, and a borrowed view that doesn’t.

Why a &str is two words wide, and a Box<str> is too

A plain &T for a sized type (&i32, &String) is a single machine word: just an address. A &str is two words — pointer and length — because str is an unsized type: the compiler doesn’t know at compile time how many bytes it spans, so the length has to travel alongside the pointer at runtime. That’s the “fat pointer.” The exact same thing is true of &[T]: pointer plus element count.

This is also why you rarely see a bare str or [T] by value — the compiler can’t put an unknown-sized thing on the stack. You meet them through a pointer that supplies the missing size: &str, &[T], Box<str>, Rc<str>. Each is the same idea — borrow or own the bytes, and carry the length next to the address so every read stays in bounds.

Key takeaways

String holds the bytes; &str just looks at them. That one split — owner versus view — is the same idea you met as ownership in lesson 4, now wearing two concrete type names you’ll type a thousand times.

Next you’ll start building your own types. Structs and enums let you bundle data into shapes that mean something, and decide for each field whether it owns its data or merely borrows a view of it.