Strings & UTF-8

Rust · Lesson 16 of 31 7 min read Updated

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 isOwned, growable text on the heapA borrowed view (slice) of text owned by someone else
Can changeYes, if the variable is mutNo
Typical sourceString::from, format!, .to_string(), reading inputString literals, slices of a String
Use it forStruct fields, building text, returning new textFunction 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.

Rust
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}");
}
Output
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.

Rust
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}");
}
Output
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:

Rust
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));
}
Output
[Rohtak, Jaipur,Pune , Lucknow]
- Rohtak
- Jaipur
- Pune
- Lucknow
true
true
Some(12)
Train 12951 departs at 17:10
TRAIN 12951 DEPARTS AT 16:55
----------
  • trim removes spaces at both ends; split(',') breaks the text at each comma. Neither creates new text: they return &str slices of the original.
  • find returns the byte position of the match inside an Option.
  • replace, to_uppercase and repeat create a new String, because the result is new text.
  • Other useful ones: split_whitespace, lines, ends_with, is_empty, split_once, chars().rev() and parse.

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:

Rust
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:?}");
}
Output
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 an Option<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.

Rust
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");
}
Output
C:\Users\Isha\notes.txt
{"name": "Isha", "city": "Pune"}
Price: ₹1,299
In stock
Use {name} for a placeholder

In println! and format!, curly brackets are placeholders, so write {{ and }} to print literal ones.

Common mistakes

  • Taking String parameters when &str would do. It forces callers to give up or clone their text. Take &str for 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. Use char_indices, find or split.
  • Comparing with different case. "Pune" == "pune" is false. Use eq_ignore_ascii_case for English text or compare to_lowercase() results.

Key points

  • String is owned, growable text; &str is a borrowed slice. Both are always valid UTF-8.
  • Build text with String::from, push_str, push and especially format!; + moves its left operand.
  • Reading methods such as trim, split, contains and find work on both types and often return slices.
  • Strings cannot be indexed by number; use chars(), char_indices() or bytes() depending on what you need.
  • Raw strings r"..." and r#"..."# 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
Rust
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));
}
Output
[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?

2. After let c = a + &b; where a and b are String values, which can you still use?

3. What does "₹99".len() return?

4. How do you print the text {total} literally with println!?

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