Skip to content
← Back to explainers

Rust explainer

String vs &str, part 1: the memory model

About 11 min to read. Allow extra time to work through examples.

String owns a growable buffer of UTF-8 bytes; &str borrows UTF-8 bytes stored somewhere else. This article shows how both are laid out in memory, where their bytes live, and which conversions copy text.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Rust has two everyday text types, String and &str, and many ways to convert between them: .to_string(), String::from, .into(), .to_owned(), .as_str(), &s[..]. The conversions are easier to remember once you know what each type holds in memory, so this article starts there and returns to the conversions at the end.

Two terms first. An owner is the value responsible for freeing some memory when it goes out of scope. A borrow (a reference such as &T) lets code read a value without becoming its owner, and the compiler checks that the borrow does not outlive the owner.

Vectors and slices

String and &str follow the same pattern as two types for sequences of values:

  • Vec<i32> owns a heap buffer of i32 values. The Vec value itself is three machine words: a pointer to the buffer, a length (how many elements are in use) and a capacity (how many fit before the buffer must grow).
  • &[i32] is a slice reference: a borrowed view of a contiguous run of i32 values that something else owns. It is two machine words: a pointer to the first element and a length. Dropping it frees nothing.

String and &str follow the same pattern

  • String owns a heap buffer of bytes that are always valid UTF-8 (the standard encoding Rust uses for text). Like Vec, it holds a pointer, a length in bytes and a capacity.
  • &str is a borrowed view of a run of UTF-8 bytes owned by something else. Like &[T], it holds a pointer and a length in bytes.
String is to Vec<u8>  as  &str is to &[u8]

You can think of a String as a Vec<u8> whose bytes are guaranteed to be UTF-8; String::into_bytes hands back that Vec<u8> without copying. Likewise a str is a run of bytes with the same guarantee, and as_bytes views it as &[u8]. The difference is the UTF-8 guarantee. The safe String API keeps it: push takes a char, not a byte, and String::from_utf8 returns an error instead of accepting invalid bytes. That guarantee is why they are separate types rather than aliases.

The comparison has limits, because text is not an array of characters. One character can take one to four bytes in UTF-8, so:

  • s[0] does not compile for a String or &str (error E0277, "the type str cannot be indexed by {integer}"), even though v[0] works for a Vec.
  • A byte range such as &s[0..1] must start and end on character boundaries. For "é", which is two bytes, &s[0..1] panics at run time. s.get(0..1) returns None instead of panicking.

Sizes

These sizes were measured with std::mem::size_of on a 64-bit target, where a pointer and a usize are 8 bytes each:

type size_of contents
String 24 pointer, length, capacity
&str 16 pointer, length
Vec<i32> 24 pointer, length, capacity
&[i32] 16 pointer, length
&i32 8 pointer

On a 32-bit target each word is 4 bytes, so each of these sizes is half as large. The Vec documentation states that a Vec is exactly a pointer, a capacity and a length, and the String documentation describes the same three components. The order of those fields is unspecified, so code should use methods such as len() and capacity() rather than depend on the layout.

Check your understanding &str is 16 bytes and &i32 is 8. Both are references. Why does one need twice the space? Show answer

Because str is a dynamically sized type: the compiler does not know how many bytes a str occupies, since that depends on the text. A reference to such a type carries extra information, called metadata, next to the address.

An i32 is always four bytes, so &i32 only needs the address. A str is "some number of UTF-8 bytes", so &str needs the address and the byte count. A reference that carries metadata is often called a fat pointer (or wide pointer).

The same holds for &[T] versus &T. It is also why let x: str = ...; does not compile (error E0277, "the size for values of type str cannot be known at compilation time"): the compiler cannot reserve a fixed amount of stack space for it. You meet str and [T] behind a reference (&str, &[T]), behind a smart pointer (Box<str>, Box<[T]>, Arc<str>), or as the contents of an owner (String, Vec<T>).

A reference to a trait object, &dyn Trait, is another fat pointer. Its metadata is a pointer to a vtable, a table holding the concrete type's method addresses, its size and alignment, and how to drop it, instead of a length.

Where the bytes live

The bytes a &str points at can live in different places. Knowing which one applies explains how long the borrow can last.

A string literal is stored in the compiled program.

let s: &'static str = "hello";

The compiler places the five bytes of "hello" in the executable, normally in a read-only data section. Creating s does not allocate at run time: s is a pointer into the program image plus the length 5. Its lifetime is 'static, meaning the bytes stay valid for the whole run of the program, so a function can return a literal as &'static str without borrowing from anything.

A String owns a heap allocation.

let s: String = String::from("hello");

This allocates a heap buffer, copies the bytes h e l l o into it, and stores a 24-byte String value (on the stack, for a local variable) holding the pointer, len() == 5 and a capacity() of at least 5. If a push or push_str would make the length exceed the capacity, the String grows its buffer: the allocator either extends it in place or provides a larger block and the bytes are copied there, so the buffer's address can change. When the owning variable goes out of scope, String's Drop implementation frees the buffer. Moving the String to another variable or function transfers that responsibility, so the buffer is freed once, by its final owner. There is no garbage collector involved. (An empty String::new() does not allocate until something is added.)

A &str can point into either one.

let owned = String::from("hello world");
let view: &str = &owned[0..5]; // points into owned's heap buffer
let lit: &str = "hello";       // points into the program's data

Both view and lit are &str, and neither owns its bytes. view is valid only while owned exists and is not modified; the borrow checker rejects code that drops, moves or mutates owned while view is still used. Lifetimes are the notation for describing how long such a borrow may last, and a later track covers them in detail.

What the split gives you

Substrings without copying. &s[6..11] creates a new pointer and length into the same bytes. It does not allocate or copy the text; it only checks that the range is in bounds and on character boundaries, panicking if not.

Signatures that state ownership. A parameter s: &str borrows the text: the caller keeps ownership and the function cannot hold on to the reference after the borrow ends (it can still copy the text into a new String). A parameter s: String takes ownership: the caller gives the value up and the function may keep it, change it or drop it. The compiler enforces both.

Callers do not need to allocate. A function taking &str accepts a literal, a whole String, a slice of a String, or text borrowed from some other buffer. None of these requires building a new String first.

Check your understanding fn greet(name: &String) compiles. Clippy (Rust's official linter) warns about it anyway, through its ptr_arg lint. Why is &str the better parameter type? Show answer

A &String is a pointer to the 24-byte String value, which in turn points to the text, so reading the text goes through two pointers. A &str is the pointer to the text plus its length. That is a small difference; the main reason is which callers each type accepts.

Everything greet can do through a &String while only reading the text (read it, iterate over it, slice it, get its length) is also available on &str. The things &String adds are String-specific methods such as capacity(), which code that only reads text rarely needs. And &str accepts more callers:

greet("world");       // a literal: works for &str; for &String it is error E0308
greet(&owned);        // a String: works for both (for &str via deref coercion, explained below)
greet(&owned[6..11]); // a substring: works for &str; for &String it is error E0308

With a &String parameter, a caller holding a literal or a substring must first allocate a new String just to make the call. The same argument applies to &Vec<T> versus &[T], and ptr_arg covers both. The lint is a warning by default; when Clippy runs with warnings treated as errors (-D warnings), it fails the check.

The conversions

// &str to String: copies the bytes into a new owned buffer
let a = "hello".to_string();
let b = String::from("hello");
let c: String = "hello".into();
let d = "hello".to_owned();

// String to &str: borrows the existing bytes, copies nothing
let e: &str = &owned;         // deref coercion
let f: &str = owned.as_str(); // explicit method
let g: &str = &owned[..];     // a slice covering the whole string

The four conversions in the first group produce the same result: a new String holding a copy of the bytes. For non-empty text that means a heap allocation. They differ in which trait provides them, which decides where each one fits:

  • to_string() comes from the ToString trait, which every type implementing Display gets, so it also works on numbers and other printable values.
  • String::from comes from the From trait and names the target type explicitly.
  • .into() comes from the Into trait and needs the target type to be known from context, as with the : String annotation above.
  • .to_owned() comes from the ToOwned trait, which converts a borrowed form into its owned form in general (for example &[T] to Vec<T> as well as &str to String).

The second group allocates nothing and copies no text. It only creates a pointer and a length referring to the String's existing buffer.

Deref coercion is what makes let e: &str = &owned; work. String implements Deref<Target = str>, so where a &str is expected and you supply a &String, the compiler converts it automatically. Method calls follow Deref too, which is why str methods can be called directly on a String: owned.len(), owned.trim() and owned.contains("ell") are all defined on str. Vec<T> has the same relationship with [T], which is why words.join(" ") works on a Vec<String> even though join is defined on slices.

Summary

  1. String owns its buffer of UTF-8 bytes; &str borrows UTF-8 bytes owned by something else, such as a String or the program's literal data.
  2. The pair mirrors Vec<u8> and &[u8], with two differences: the UTF-8 guarantee, and the rule that text cannot be indexed by integer and must be sliced on character boundaries.
  3. A common default: take &str for parameters that only read text, take String when the function needs to keep the text, return a String when the function builds new text, and store String in structs that own their text. Returning a &str that borrows from an argument is also valid when the result is part of the input.
  4. Getting a &str from a String copies nothing; getting a String from a &str copies the bytes and, for non-empty text, allocates.

Item 1.23 practises these signatures, with Clippy's ptr_arg checking the parameter types. Item 1.24 looks at the UTF-8 bytes underneath and how characters map onto them.

step 22 of 24

Ground Rules: Values, Types, Control Flow

Up next String vs &str, part 2: functions that take and return text