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.

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
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());
}hello hello ell
sizes: &str = 16 bytes, String = 24 bytes
len=5 capacity=5literal 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:
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());
}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=40The 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:
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
}A LITERAL
OWNED TEXT
OWNEDAll 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:
fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
println!("{}", shout("a literal"));
}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:
fn make_greeting(name: &str) -> &str {
let greeting = format!("hello, {name}");
&greeting
}
fn main() {
println!("{}", make_greeting("Ferris"));
}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:
fn make_greeting(name: &str) -> String {
format!("hello, {name}")
}
fn main() {
println!("{}", make_greeting("Ferris"));
}hello, FerrisYou 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:
fn main() {
let word = String::from("hello");
let first = word[0];
println!("{first}");
}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:
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
}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:
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}");
}rust.hacks
x-ya + "." + &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 have | You want | Use |
|---|---|---|
| A function that reads text | Accept any string | &str parameter |
| A struct that must keep text | Own it | String field |
| Text built inside a function | Hand it to the caller | Return String |
| A literal known at compile time | Constant text | &'static str |
| A piece of an existing string | No copy | A &str slice on a character boundary |
| Many pieces joined | One result | format! 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.


