Strings in Rust are UTF-8 text sequences represented as owned String or borrowed &str.
If you’re coming from JavaScript, Python, or C#, this is going to hurt a bit at first.
In those languages, there’s only one “String” type. You do s = "hello", then s += " world", and if you want the first letter, you do s[0]. Easy.
In Rust, try doing that and the compiler will throw three different errors at you. Rust has two main string types: String and &str. And to make matters worse, it doesn’t let you access letters by index.
Why does Rust complicate something so basic? The short answer is: Memory Management and UTF-8. Today, we’re going to understand it once and for all.
The Two Main Types: String and &str
To understand the difference, we need to think about Ownership and memory.
String: The owner
- Where it lives: Metadata on the Stack, content in the Heap.
- Ownership: It owns its data (
Owner). - Capacity: It can grow and shrink when we have mutable access.
- Cost: Creating owned content usually requires allocating memory via the allocator and copying bytes.
It’s the equivalent of a StringBuilder in Java or a std::string in C++. It’s a vector of bytes (Vec<u8>) in disguise.
// We create an empty String that can grow
let mut s = String::new();
s.push_str("Hello");
s.push('!');&str: A borrowed view
- Where it lives: It’s a reference (
&) to some location (it can be on the Heap, the Stack, or the static memory of the executable). - Ownership: It doesn’t own it, it only borrows (
Borrowed). - Capacity: Fixed size. You cannot add text to it.
- Cost: Copying the reference itself is cheap; its representation contains a pointer and a length.
It’s known as a String Slice. It’s a window to look at text that belongs to someone else.
The photo analogy
Stringis the house itself. You can paint the walls, build an extra room, or demolish it. You hold the deed.&stris a window through which you look at a part of the house. You can’t renovate it from that reference, and Rust prevents the window from outliving the house.
When to use which?
The practical recommendation for your functions is this:
- If you need to store the text in a Struct for later use: Use
String. - If you only need to read the text to analyze or print it: Use
&str.
// ✅ Good: We accept &str, so both Strings and literals can be passed
fn greet(name: &str) {
println!("Hello, {}", name);
}
fn main() {
let name_string = String::from("Luis");
let name_literal = "Juan";
greet(&name_string); // Rust automatically converts &String to &str
greet(name_literal);
}Conversions
Converting from one to the other is an everyday task in Rust.
From &str to String
When you have a reference and want to become the owner to modify or store it, you must allocate memory and copy the bytes.
let slice = "hello";
let s1 = slice.to_string(); // Option A (Most common)
let s2 = String::from(slice); // Option B
let s3 = slice.to_owned(); // Option C (Idiomatic if you're coming from Cow)From String to &str
This is very cheap.
let s = String::from("hello");
let slice = &s; // Deref coercion: &String behaves like &str
let slice2 = &s[..]; // Explicit slice of the entire stringThe problem with s[0] and UTF-8
Try to compile this:
let s = String::from("Hello");
let letter = s[0]; // ❌ Error: `String` cannot be indexed by `{integer}`Why won’t Rust let me read the first letter? Isn’t it an array of characters?
No. A String is a buffer of bytes (u8) that always contains valid UTF-8, not an array where each position corresponds to one character.
In UTF-8, a character can take between 1 and 4 bytes.
'a'takes 1 byte.'ñ'takes 2 bytes.'€'takes 3 bytes.'😀'takes 4 bytes.
If Rust let you do s[0] on the word "Spain", everything would be fine. But if you do it on "Ñu", s[0] would return half of the Ñ, which is not a valid character. Rust prefers not to compile rather than give you garbage.
How to iterate then?
If you want to access the characters, you must explicitly tell Rust how you want to interpret the bytes.
As bytes (.bytes())
If you want the raw data.
for b in "Ñu".bytes() {
println!("{}", b); // Prints 195, 145, 117
}As Unicode values (.chars())
If you want to iterate over Unicode scalar values.
for c in "Ñu".chars() {
println!("{}", c); // Prints Ñ, u
}.chars() does not always return what a person perceives as a complete letter. For example, a grapheme with combining marks can contain several Unicode values. Segmenting graphemes requires specific tools.
String Concatenation
Joining strings also has its trick due to ownership.
Using the + operator
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
// Note: s1 is MOVED and ceases to exist. s2 is passed by reference.
let s3 = s1 + &s2;The + operator actually calls a method add(self, s: &str). It takes ownership of the first, appends the content of the second (without copying the second, only reading it), and returns the result. It’s efficient, but it makes s1 die.
Using the format! macro
If you want to concatenate without losing variables and in a more readable way, use format!. It works like println! but returns a String instead of printing it.
let s1 = String::from("Tic");
let s2 = String::from("Tac");
let s3 = format!("{}-{}", s1, s2); // Creates a new String. s1 and s2 remain alive.This option creates a new String and is usually easier to read when combining several parts. Its exact cost depends on the strings and the capacity that needs to be allocated.