Strings & UTF-8
Text is everywhere: names, addresses, messages, file contents. Rust's strings surprise newcomers in two ways. There are two main string types instead of one, and you cannot read the "third letter" with text[2]. Both decisions come from Rust's promise to handle every language's text correctly.
In this lesson you will learn when to use String and when to use &str, how to build and change text, the most useful string methods, and how UTF-8 affects lengths and loops.
String and &str
String | &str | |
|---|---|---|
| What it is | Owned, growable text on the heap | A borrowed view (slice) of text owned by someone else |
| Can change | Yes, if the variable is mut | No |
| Typical source | String::from, format!, .to_string(), reading input | String literals, slices of a String |
| Use it for | Struct fields, building text, returning new text | Function parameters that only read text |
Both always hold valid UTF-8 text. A String can lend itself as a &str (&name or name.as_str()), and a &str becomes an owned String with .to_string() or String::from.
fn main() {
let shop: &str = "Meera's Bakery";
let mut order = String::from("Order: ");
order.push_str("2 cakes");
order.push(',');
order.push_str(" 6 cookies");
println!("{shop} | {order}");
println!("Length: {} bytes", order.len());
let greeting = format!("Thank you, {}!", "Rohan");
let owned: String = shop.to_string();
let borrowed: &str = &owned;
println!("{greeting} {borrowed}");
}Meera's Bakery | Order: 2 cakes, 6 cookies Length: 25 bytes Thank you, Rohan! Meera's Bakery
push_str appends a &str, while push appends a single char (single quotes). format! builds a new String using the same placeholders as println!, and is usually the clearest way to assemble text.
Joining strings with +
The + operator also joins strings, but it has an ownership twist: it takes ownership of the left String and borrows the right side as &str.
fn main() {
let first = String::from("Ananya");
let last = String::from("Iyer");
let full = first + " " + &last; // `first` is moved into `full`
println!("{full}");
// println!("{first}"); // error: borrow of moved value `first`
let city = "Kochi";
let label = format!("{full} ({city}), {last}");
println!("{label}");
}Ananya Iyer Ananya Iyer (Kochi), Iyer
+ reuses the memory of first instead of copying, which is efficient, but it makes first unusable and needs an & on the right. For anything more than two pieces, format! is easier to read and borrows everything.
Everyday string methods
Methods that only read text are defined on str, so they work on both String and &str:
fn main() {
let raw = " Rohtak, Jaipur,Pune , Lucknow ";
let cleaned = raw.trim();
println!("[{cleaned}]");
for city in cleaned.split(',') {
println!("- {}", city.trim());
}
let msg = "Train 12951 departs at 16:55";
println!("{}", msg.contains("12951"));
println!("{}", msg.starts_with("Train"));
println!("{:?}", msg.find("departs"));
println!("{}", msg.replace("16:55", "17:10"));
println!("{}", msg.to_uppercase());
println!("{}", "-".repeat(10));
}[Rohtak, Jaipur,Pune , Lucknow] - Rohtak - Jaipur - Pune - Lucknow true true Some(12) Train 12951 departs at 17:10 TRAIN 12951 DEPARTS AT 16:55 ----------
trimremoves spaces at both ends;split(',')breaks the text at each comma. Neither creates new text: they return&strslices of the original.findreturns the byte position of the match inside anOption.replace,to_uppercaseandrepeatcreate a newString, because the result is new text.- Other useful ones:
split_whitespace,lines,ends_with,is_empty,split_once,chars().rev()andparse.
Why text[2] does not compile
In UTF-8, an English letter takes 1 byte, ₹ takes 3 bytes and most Hindi, Tamil or Malayalam characters take 3 bytes each. Reading "byte number 2" could land in the middle of a character, and even a correct character position would need a slow scan from the start. So Rust does not allow text[2]; the compiler reports that the type str cannot be indexed by an integer. You choose what you mean instead:
fn main() {
let word = "नमस्ते";
println!("Bytes: {}", word.len());
println!("Chars: {}", word.chars().count());
for (i, c) in "₹50".char_indices() {
println!("byte {i}: {c}");
}
let name = "Kabir";
let third = name.chars().nth(2);
println!("Third letter of {name}: {third:?}");
}Bytes: 18
Chars: 6
byte 0: ₹
byte 3: 5
byte 4: 0
Third letter of Kabir: Some('b').bytes()gives the raw bytes,.chars()gives Unicode characters (char), and.char_indices()gives each character with its byte position, which is safe to use for slicing.chars().nth(2)walks to the third character and returns anOption<char>.
A Rust char is one Unicode code point. "नमस्ते" has 6 code points (some are vowel signs and a virama that combine with letters), yet a reader sees fewer letters. If you need user-visible letters, for example to cut a name to 10 letters for a label, use the unicode-segmentation crate, which splits text into grapheme clusters.
Escapes, raw strings and braces
Inside normal string literals, a backslash starts an escape: \n new line, \t tab, \" a quote, \\ a backslash and \u{20B9} any Unicode character by its code. Raw strings r"..." turn escapes off, which is handy for Windows paths and regular expressions. Add # signs, as in r#"..."#, when the text itself contains quotes.
fn main() {
let path = r"C:\Users\Isha\notes.txt";
let json = r#"{"name": "Isha", "city": "Pune"}"#;
let price = "Price: \u{20B9}1,299\nIn stock";
println!("{path}");
println!("{json}");
println!("{price}");
println!("Use {{name}} for a placeholder");
}C:\Users\Isha\notes.txt
{"name": "Isha", "city": "Pune"}
Price: ₹1,299
In stock
Use {name} for a placeholderIn println! and format!, curly brackets are placeholders, so write {{ and }} to print literal ones.
Common mistakes
- Taking
Stringparameters when&strwould do. It forces callers to give up or clone their text. Take&strfor read-only text. - Using
len()as a letter count. It counts bytes; use.chars().count()(or grapheme clusters for what users see). - Slicing text with fixed byte numbers.
&name[0..3]panics if byte 3 is inside a character. Usechar_indices,findorsplit. - Comparing with different case.
"Pune" == "pune"is false. Useeq_ignore_ascii_casefor English text or compareto_lowercase()results.
Key points
Stringis owned, growable text;&stris a borrowed slice. Both are always valid UTF-8.- Build text with
String::from,push_str,pushand especiallyformat!;+moves its left operand. - Reading methods such as
trim,split,containsandfindwork on both types and often return slices. - Strings cannot be indexed by number; use
chars(),char_indices()orbytes()depending on what you need. - Raw strings
r"..."andr#"..."#skip escapes;{{and}}print braces in format strings.
Exercise
Write a function fn make_username(full_name: &str, year: u32) -> String that takes a name such as " Priya Sharma ", trims it, takes the first word in lowercase and the first letter of the last word in lowercase, and joins them with the year: priya.s2026. Return an empty String if the name has fewer than two words. Test it with three names.
Show solution
fn make_username(full_name: &str, year: u32) -> String {
let words: Vec<&str> = full_name.split_whitespace().collect();
if words.len() < 2 {
return String::new();
}
let first = words[0].to_lowercase();
let initial = words[words.len() - 1].chars().next().unwrap().to_lowercase();
format!("{first}.{initial}{year}")
}
fn main() {
println!("[{}]", make_username(" Priya Sharma ", 2026));
println!("[{}]", make_username("Farhan Ali Khan", 2025));
println!("[{}]", make_username("Meera", 2026));
}[priya.s2026] [farhan.k2025] []
split_whitespace ignores extra spaces, so no separate trim is needed. collect() gathers the words into a Vec<&str> (you will learn it properly in Iterators). The unwrap is safe because every word has at least one character. char::to_lowercase returns an iterator-like value that prints as the lowercase letter.
Quick Quiz
1. Which type should a function parameter use if it only reads text?
&str borrows the text and accepts both literals and String values.
2. After let c = a + &b; where a and b are String values, which can you still use?
+ takes ownership of the left String, so a is moved; b was only borrowed.
3. What does "₹99".len() return?
₹ takes 3 bytes in UTF-8 and each digit 1 byte, and len counts bytes.
4. How do you print the text {total} literally with println!?
Doubling the braces escapes them in format strings.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
