The two types
You're building a config loader. You read a line from a file, parse it, and extract a value. You want to store that value in a struct. You write let host = "localhost"; and the compiler rejects you. You try let host = String::from("localhost"); and it compiles. Then you pass host to a function that expects &str and it works. Then you try to pass a literal to a function expecting String and it fails.
Rust has two string types. This feels redundant coming from Python or JavaScript, where a string is just a string. The split exists because Rust separates the data from the ownership of that data. You always have to decide who pays for the memory.
String is an owned, growable buffer on the heap. It's the notebook. You allocated the paper, you can write more, you are responsible for throwing it away when done. &str is a string slice. It's a view into some text. It doesn't own the bytes. It just points to a start and an end. You can have a view into a String, or a view into a static string baked into your program. The slice is cheap to copy because it's just two numbers: a pointer and a length.
The slice is the view. The String is the asset.
Minimal example
fn main() {
// String literals live in read-only memory for the entire program lifetime.
// The type is &str, specifically &'static str.
let greeting: &str = "Hello";
// String allocates a buffer on the heap.
// It owns the data and can grow or shrink.
let mut name = String::from("World");
// push_str expects a &str.
// Rust automatically borrows the String to get a &str view.
name.push_str(", ");
name.push_str(greeting);
println!("{name}");
}
When you write String::from("text"), Rust allocates memory on the heap, copies the bytes from the literal into that heap memory, and gives you a handle to manage it. The handle contains a pointer to the heap, the current length, and the capacity. Capacity is how much space is reserved. If you push more data and exceed capacity, Rust allocates a bigger chunk, moves the data, and frees the old chunk. This growth strategy is amortized O(1), so repeated pushes are fast.
When you have a String, you can get a &str by borrowing it. &my_string gives you a slice. This is zero-cost. No allocation. Just a pointer and length. This is why functions accept &str. They don't care where the text lives. They just want to read it.
Convention aside: String::from and to_string() both create owned strings from literals. The community prefers to_string() for converting other types like integers or floats, because it comes from the ToString trait. For literals, String::from is explicit, but literal.to_string() is also idiomatic and slightly shorter. Pick one and be consistent.
Realistic example
/// Stores a configuration pair.
/// Both fields are Strings because the struct owns the data.
struct ConfigEntry {
key: String,
value: String,
}
/// Parses a line like "key=value" into an owned ConfigEntry.
/// Takes &str to accept literals, Strings, or slices without forcing ownership.
fn parse_line(line: &str) -> Option<ConfigEntry> {
// splitn returns an iterator of &str slices pointing into the original line.
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() != 2 {
return None;
}
// The slices borrow from `line`.
// We need owned Strings to store them in the struct.
// to_string() allocates a new String and copies the bytes.
Some(ConfigEntry {
key: parts[0].to_string(),
value: parts[1].to_string(),
})
}
fn main() {
// This literal is &'static str.
// It coerces to &str when passed to parse_line.
let raw_line = "database_host=localhost";
if let Some(entry) = parse_line(raw_line) {
println!("Key: {}, Value: {}", entry.key, entry.value);
}
}
Notice the function signature takes &str. This is a deliberate choice. If the function took String, the caller would have to allocate a String before calling, even if they only had a literal. If it took &String, the caller would be forced to have a String and couldn't pass a literal directly. &str is the most flexible input type. It accepts everything.
Strings are UTF-8
Rust strings are always valid UTF-8. This means a String is a sequence of bytes that form valid Unicode characters. A character can be 1 to 4 bytes. The letter c is one byte. The character é is two bytes. The emoji 🦀 is four bytes.
This breaks the habit of indexing strings by integer. In Python, s[5] gives you the sixth character. In Rust, s[5] gives you the sixth byte, which might be in the middle of a multi-byte character. Accessing a partial character is invalid. The compiler prevents this entirely. You cannot index a String or &str with an integer.
fn main() {
let s = String::from("café");
// This is forbidden.
// The compiler rejects it with E0608 (cannot index into a `String`).
// let first = s[0];
// To get characters, iterate.
// chars() yields valid Unicode scalar values.
for c in s.chars() {
println!("{c}");
}
}
If you need random access to characters, you have to walk the string from the start, counting characters until you reach the index. This is O(n). If your algorithm requires frequent random access by character index, a String is the wrong tool. Consider storing indices into the byte buffer if you only need byte-level access, or use a crate designed for grapheme clusters if you need user-perceived characters.
Iterate with .chars(). Indexing strings is a trap.
Common pitfalls
Holding a reference to a String that gets dropped is a classic error. The slice points into the String's buffer. If the String is dropped, the buffer is freed. The slice becomes a dangling pointer. The borrow checker catches this at compile time.
fn dangling_reference() {
let slice: &str;
{
let owned = String::from("temporary");
// ERROR: owned doesn't live long enough.
// The compiler rejects this with E0597.
slice = &owned;
}
// owned is dropped here.
// slice would point to freed memory.
}
Another pitfall is trying to modify a &str. Slices are immutable views by default. You can't call push_str on a &str. You need a &mut String to mutate. If you have a &str and need to modify it, you must convert it to a String first, which allocates.
fn main() {
let text: &str = "immutable";
// ERROR: &str has no method push_str.
// text.push_str(" text");
// Convert to String to mutate.
let mut owned = text.to_string();
owned.push_str(" text");
}
Convention aside: API design matters. If your function signature takes &String, you're making your callers do extra work. They have to ensure they have a String and borrow it. Change the signature to &str. It accepts String, &String, and literals. The only reason to take &String is if you need to check the capacity or perform some operation specific to the owned buffer, which is rare.
If your function takes &String, you're being lazy. Change it to &str.
When to use what
Use &str for function parameters when you only need to read the text. This accepts literals, owned strings, and slices without forcing the caller to allocate or give up ownership.
Use String when you need to modify the text, such as appending characters, replacing substrings, or building a string incrementally.
Use String when you need to store text in a data structure that outlives the current scope, like a struct field or a vector that persists across function calls.
Use String::from or to_string when converting a &str slice into an owned buffer so you can take ownership of the data.
Use string literals for compile-time constants like keys, error messages, or configuration defaults that never change.
Prefer slices for reading. Reach for owned strings only when you must write or store.