Skip to content
rusthacks.dev
Rust

Rust Strings Explained and When to Use String or str

String owns its text and str is a borrowed view of text somewhere else. Use that split to pick the right type in function signatures and structs.

7 min read
Summarize in AI
Flat illustration for the post “Rust Strings Explained and When to Use String or str”, composed around enums.

Rust has two main string types because it separates owning text from looking at text. Most of the confusion about them disappears once you see what each one actually is in memory, so this post starts there and then follows the places the two meet: function parameters, return values, indexing and joining. All programs ran on Rust 1.99, edition 2024.

What each type is

src/main.rsrust
fn main() {
    let literal: &str = "hello";              // borrowed, lives in the binary
    let owned: String = String::from("hello"); // owned, lives on the heap
    let slice: &str = &owned[1..4];            // a borrowed view into the String

    println!("{literal} {owned} {slice}");
    println!("sizes: &str = {} bytes, String = {} bytes", size_of::<&str>(), size_of::<String>());
    println!("len={} capacity={}", owned.len(), owned.capacity());
}
Text
hello hello ell
sizes: &str = 16 bytes, String = 24 bytes
len=5 capacity=5

literal is a &str: a pointer to bytes plus a length, 16 bytes in total on a 64-bit machine. The bytes live inside the compiled program, so the reference is valid for the whole run. owned is a String: a pointer, a length and a capacity, 24 bytes, and the characters sit in a separate heap allocation that the String frees when it is dropped. slice is a &str again, a window into owned from byte 1 to byte 4, which copies nothing.

That is the whole distinction. A &str is a view that something else owns. A String is the owner, and being the owner it can grow, shrink and change. Because a &str does not own anything, it is the right type for reading text, and because a String does, it is the right type for keeping it.

How a String grows

Capacity is the room reserved on the heap; length is how much is used. Pushing past the capacity reallocates, and the pattern of reallocations is easy to see:

src/main.rsrust
fn main() {
    let mut s = String::new();
    let mut last = s.capacity();
    println!("start: len=0 capacity={last}");
    for i in 0..40 {
        s.push('x');
        if s.capacity() != last {
            last = s.capacity();
            println!("after {:>2} pushes: capacity={last}", i + 1);
        }
    }
    let presized = String::with_capacity(40);
    println!("with_capacity(40): capacity={}", presized.capacity());
}
Text
start: len=0 capacity=0
after  1 pushes: capacity=8
after  9 pushes: capacity=16
after 17 pushes: capacity=32
after 33 pushes: capacity=64
with_capacity(40): capacity=40

The first push allocates 8 bytes. The ninth, seventeenth and thirty-third each trigger a reallocation to twice the previous size, so building a 40-character string by pushing one character at a time performed four allocations and copied the contents each time. If you know the final size, String::with_capacity(40) does it once, as the last line shows. For a loop that appends thousands of pieces this is a cheap speedup; for a handful of pieces it does not matter.

Parameters: ask for the borrowed form

The rule that saves the most trouble is to accept &str when a function only reads text:

src/main.rsrust
fn shout(text: &str) -> String {
    text.to_uppercase()
}

fn main() {
    let owned = String::from("owned text");
    println!("{}", shout("a literal"));
    println!("{}", shout(&owned));          // &String coerces to &str
    println!("{}", shout(&owned[0..5]));    // a slice works too
}
Text
A LITERAL
OWNED TEXT
OWNED

All three calls work. A string literal is already a &str. A &String is converted to &str automatically (deref coercion), so you pass &owned, and a slice of a String is a &str too. A function that accepts &str therefore takes everything. The alternative, a String parameter, takes almost nothing without extra work:

src/main.rsrust
fn shout(text: String) -> String {
    text.to_uppercase()
}

fn main() {
    println!("{}", shout("a literal"));
}
Text
error[E0308]: mismatched types
 --> src/main.rs:6:26
  |
6 |     println!("{}", shout("a literal"));
  |                    ----- ^^^^^^^^^^^ expected `String`, found `&str`
  |                    |
  |                    arguments to this function are incorrect
  |
note: function defined here
 --> src/main.rs:1:4
  |
1 | fn shout(text: String) -> String {
  |    ^^^^^ ------------
help: try using a conversion method
  |
6 |     println!("{}", shout("a literal".to_string()));
  |                                     ++++++++++++

E0308, expected String, found &str. The compiler even suggests the conversion, .to_string(), but that allocates a copy just to satisfy a signature that did not need ownership. Taking String is correct when the function will store or consume the text (a struct field, a message sent to another thread). Otherwise take &str. The deeper rules are in ownership, and the error is catalogued in E0308.

Return values: return the owner

The mirror-image rule applies to results. If a function builds new text, it must give the caller ownership of it. Returning a reference to something created inside the function cannot work, because the local is destroyed when the function ends:

src/main.rsrust
fn make_greeting(name: &str) -> &str {
    let greeting = format!("hello, {name}");
    &greeting
}

fn main() {
    println!("{}", make_greeting("Ferris"));
}
Text
error[E0515]: cannot return reference to local variable `greeting`
 --> src/main.rs:3:5
  |
3 |     &greeting
  |     ^^^^^^^^^ returns a reference to data owned by the current function

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0515`.

E0515, cannot return reference to local variable. The fix is to return the String itself, which moves the allocation out to the caller:

src/main.rsrust
fn make_greeting(name: &str) -> String {
    format!("hello, {name}")
}

fn main() {
    println!("{}", make_greeting("Ferris"));
}
Text
hello, Ferris

You can return a &str when it points into data the caller passed in, such as fn first_word(text: &str) -> &str, because the caller's text outlives the call. When the compiler asks for a lifetime annotation there, E0597 and its relatives explain what it is checking.

Indexing: why word[0] is rejected

Programmers from other languages reach for word[0] and are stopped:

src/main.rsrust
fn main() {
    let word = String::from("hello");
    let first = word[0];
    println!("{first}");
}
Text
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:22
  |
3 |     let first = word[0];
  |                      ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>

A String is UTF-8, where a character occupies one to four bytes. The expression word[0] would have to choose between the first byte and the first character, which are different things for any non-ASCII text, and Rust refuses to guess. The note in the message points to the two honest alternatives: .chars().nth(0) for the first character and .bytes().nth(0) for the first byte.

You can slice by byte range, which is where the cost of the choice shows up:

src/main.rsrust
fn main() {
    let word = String::from("héllo");
    println!("len = {} bytes, chars = {}", word.len(), word.chars().count());
    println!("{:?}", word.char_indices().map(|(i, c)| (i, c)).collect::<Vec<_>>());
    println!("{}", &word[0..1]);     // 'h' is 1 byte
    println!("{}", &word[0..3]);     // 'h' + 'é' (2 bytes)
    println!("{}", &word[0..2]);     // cuts 'é' in half
}
Text
len = 6 bytes, chars = 5
[(0, 'h'), (1, 'é'), (3, 'l'), (4, 'l'), (5, 'o')]
h
hé

thread 'main' panicked at src/main.rs:7:25:
end byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3 of string)

The word héllo has 5 characters and 6 bytes, because é takes two. char_indices shows where each character starts: h at 0, é at 1, l at 3. The slice 0..3 is a clean cut that ends after the é, but 0..2 ends in the middle of it, and the program panics with a message that names the character it would have split. The rule for production code: never slice with byte offsets you computed yourself. Use offsets from char_indices, find or split_at on a boundary you know, or iterate over chars().

Joining text

Two tools, with different ownership effects:

src/main.rsrust
fn main() {
    let a = String::from("rust");
    let b = String::from("hacks");
    let joined = a + "." + &b;         // a is moved; b is only borrowed
    println!("{joined}");
    // println!("{a}");                // would not compile: a was moved

    let c = format!("{}-{}", "x", "y"); // format! borrows everything
    println!("{c}");
}
Text
rust.hacks
x-y

a + "." + &b uses String's + operator, whose left side is taken by value. a is moved into the expression and cannot be used again; b is only borrowed. The commented-out line would fail with a borrow-of-moved-value error. For three or more pieces, format! is clearer, because it borrows all its arguments and returns a fresh String. For many pieces in a loop, create one String with with_capacity and call push_str, which appends in place.

A short decision table

You haveYou wantUse
A function that reads textAccept any string&str parameter
A struct that must keep textOwn itString field
Text built inside a functionHand it to the callerReturn String
A literal known at compile timeConstant text&'static str
A piece of an existing stringNo copyA &str slice on a character boundary
Many pieces joinedOne resultformat! or push_str into one String

When this does not apply

Not all text is UTF-8. File names, environment variables and bytes read from a socket have their own types (OsString, Path, Vec<u8>), and forcing them into String is how programs break on the one user whose name is not valid Unicode. Programs that must be fast with many small strings sometimes use Cow<str> to avoid copies or interned strings to share them; both are optimisations to reach for after you have measured, and everything above remains true underneath them.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.