Strings
Reviewed & published by Brayan K
Rust is a systems programming language focused on speed, memory safety, and fearless concurrency — and its text handling is built on UTF-8 with two complementary types: the owned String and the borrowed &str.
Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll create and grow strings, concatenate and format them, use common methods, and understand why .len() counts bytes.
What You'll Learn in This Lesson
- The difference between String and &str
- Creating and growing a String
- Concatenating with + and format!
- Common methods: trim, replace, contains
- Iterating with .chars() and .bytes()
- Why .len() counts bytes, not characters
1️⃣ Creating and Growing Strings
There are two string types to know. A &str is a borrowed slice (string literals are &str), while a String is owned and growable. You build a String with String::from or .to_string(), then grow it with push_str and push.
fn main() {
// Two main string types:
// &str — a borrowed, fixed string slice (literals are &str)
// String — an owned, growable string on the heap
let literal: &str = "hello"; // a string slice
let owned: String = String::from("hello"); // an owned String
let also_owned = "world".to_string(); // another way to make a String
println!("{} {} {}", literal, owned, also_owned);
// Grow a String with push_str (a slice) and push (a single char).
let mut greeting = String::from("Hi");
greeting.push_str(", there"); // append a &str
greeting.push('!'); // append a char
println!("{}", greeting); // -> Hi, there!
// .len() is the number of BYTES, not characters.
println!("byte length: {}", greeting.len()); // -> byte length: 10
}Note that .len() reported 10 bytes. For this ASCII text that equals the character count, but for accented letters or emoji it would not — strings are UTF-8.
2️⃣ Concatenation, Formatting, and Methods
You can join strings with + (which moves the left side and borrows the right), but the more flexible tool is format!, which builds a String without moving anything. Strings also come with a rich set of methods: trim, to_uppercase, contains, replace, and more.
fn main() {
let a = String::from("foo");
let b = String::from("bar");
// Concatenate with + : the left side is moved, the right side is borrowed.
let combined = a + &b; // a is moved here; b is borrowed
println!("{}", combined); // -> foobar
// format! builds a String without moving any of its arguments.
let name = "Ada";
let msg = format!("Hello, {}! You have {} messages.", name, 3);
println!("{}", msg); // -> Hello, Ada! You have 3 messages.
// Common, handy methods:
let text = " Rust Lang ";
println!("trimmed: '{}'", text.trim()); // -> 'Rust Lang'
println!("upper: {}", "rust".to_uppercase()); // -> RUST
println!("contains 'Lang'? {}", text.contains("Lang")); // -> true
println!("replaced: {}", "a-b-c".replace("-", "/")); // -> a/b/c
// Iterate over characters (NOT bytes) safely.
for c in "abc".chars() {
print!("[{}]", c); // -> [a][b][c]
}
println!();
}Because you can't index a string by position, you iterate with .chars() to walk characters safely. This respects UTF-8 boundaries automatically.
Your turn. Fill in the blanks marked ___, then run it.
// 🎯 YOUR TURN — finish the string handling, then run it.
fn main() {
// 1) Start with an owned String.
let mut sentence = String::from("rust");
// 2) Append " is great" to it (a string slice).
sentence.___(" is great"); // 👉 the method that appends a &str
println!("{}", sentence); // expected: rust is great
// 3) Build a greeting with format! (no moving of name).
let name = "Sam";
let greeting = ___!("Hi, {}!", name); // 👉 the macro that builds a String
println!("{}", greeting); // expected: Hi, Sam!
// 4) Print the uppercase version of "go".
println!("{}", "go".to_uppercase()); // expected: GO
}
// ✅ Expected output:
// rust is great
// Hi, Sam!
// GOMini-Challenge: A Word Counter
Count words and characters and uppercase a sentence. Run it with cargo run.
// 🎯 MINI-CHALLENGE: a word counter.
//
// 1. Take the string "the quick brown fox" (a &str).
// 2. Use .split_whitespace() to break it into words; .count() counts them.
// (split_whitespace returns an iterator; .count() consumes it.)
// 3. Print "Words: 4".
// 4. Use .to_uppercase() to print the whole sentence in capitals.
// 5. Use .chars().count() to print the number of characters (no spaces? keep
// it simple — just count all chars including spaces).
//
// ✅ Example output:
// Words: 4
// THE QUICK BROWN FOX
// Chars: 19
fn main() {
// your code here
}Common Errors (and the fix)
- "the type `str` cannot be indexed by `{integer}`" — You can't do s[0]. Use .chars().nth(0) or a byte-range slice instead.
- "expected `String`, found `&str`" — Convert with .to_string() or String::from(...).
- "cannot add `String` to `String`" — With + the right side must be a &str: write a + &b.
- "value used here after move" after + — + moves its left operand. Use format! if you need to keep both pieces.
Pro Tips
- 💡 Reach for format!: it's the clearest way to assemble strings and never moves your inputs.
- 💡 Iterate with .chars(): it walks Unicode characters correctly; .bytes() walks raw bytes.
- 💡 Take &str parameters: functions that accept &str work with both literals and Strings.
📋 Quick Reference — Strings
| Goal | Rust Syntax |
|---|---|
| Make a String | String::from("hi") |
| Append slice | s.push_str("!") |
| Append char | s.push('!') |
| Build with values | format!("{} {}", a, b) |
| Iterate chars | for c in s.chars() { } |
| Byte length | s.len() |
🎉 Lesson Complete!
- ✅ String is owned and growable; &str is a borrowed slice
- ✅ Grow with push_str/push; join with + or format!
- ✅ Strings are UTF-8; .len() is bytes, not characters
- ✅ Iterate with .chars(); you can't index by position
Practice quiz
What is the difference between String and &str?
- No difference
- &str is owned; String is borrowed
- String is owned and growable; &str is a borrowed slice
- String is on the stack
Answer: String is owned and growable; &str is a borrowed slice. String owns growable heap data; &str is a borrowed, fixed view.
What type is a string literal like "hello"?
- &str
- String
- char
- Vec<u8>
Answer: &str. String literals have type &str (specifically &'static str).
Which method appends a &str to a String?
- push
- add_str
- concat
- push_str
Answer: push_str. push_str appends a string slice; push appends a single char.
What does String::len() return?
- The number of characters
- The number of bytes
- The capacity
- The number of words
Answer: The number of bytes. len() returns the byte length, since strings are UTF-8.
Why can't you index a string with s[0]?
- UTF-8 bytes may not align to characters
- It is too slow
- Strings are immutable
- Indexing is unsafe
Answer: UTF-8 bytes may not align to characters. A byte index could land mid-character in UTF-8, so direct indexing is disallowed.
What does "foo" + &String::from("bar") via let c = a + &b produce?
- foo bar
- barfoo
- foobar
- an error
Answer: foobar. + concatenates, moving the left String and borrowing the right, giving foobar.
Which is the cleanest way to build a String from several pieces without moving them?
- The + operator
- format!
- push
- to_string
Answer: format!. format! borrows its arguments and returns a new String.
How do you iterate over characters safely?
- .bytes()
- .len()
- [i]
- .chars()
Answer: .chars(). .chars() walks Unicode characters respecting UTF-8 boundaries.
What does "rust".to_uppercase() return?
- rust
- RUST
- Rust
- an error
Answer: RUST. to_uppercase returns RUST.
What does "the quick brown fox".split_whitespace().count() return?
- 3
- 19
- 4
- 1
Answer: 4. There are 4 whitespace-separated words.
Continue this course
- Previous: Vectors
- Next: HashMaps — Map keys to values for fast lookups with HashMap<K, V>
- Quick reference: Rust cheat sheet
Frequently asked questions
What is the difference between String and &str?
String is an owned, growable string stored on the heap — you can push onto it and it owns its data. &str (a string slice) is a borrowed, fixed-length view into string data; it does not own anything. String literals like "hello" are &str. A common pattern is to take &str as function parameters for flexibility and to use String when you need to build or own text. A String can be borrowed as &str automatically, but going the other way (turning a &str into a String) requires an explicit conversion like .to_string().
Why does String::len return bytes instead of characters?
Rust strings are UTF-8 encoded, and in UTF-8 a single character can take anywhere from one to four bytes. The .len() method reports the number of bytes the string occupies, which for plain ASCII text equals the character count but for accented letters or emoji does not. If you want the number of Unicode characters, use .chars().count(). This byte-versus-character distinction is also why you cannot index a string with [i] in Rust.
Why can't I index a string like s[0]?
Because strings are UTF-8, byte index 0 might be only part of a multi-byte character, so returning a single byte could give a meaningless fragment. To prevent silent bugs, Rust disallows direct indexing of strings. Instead, you iterate characters with .chars(), iterate bytes with .bytes(), or take a slice with a byte range like &s[0..4] (which must fall on character boundaries or it will panic).
How do I build a string from several pieces?
The cleanest way is the format! macro, which works like println! but returns a String instead of printing: let s = format!("{} {}", a, b). It borrows its arguments, so nothing gets moved. You can also use the + operator (which moves the left operand and borrows the right), push_str to append a slice, or push to append a single char. For joining many pieces, building with format! or pushing in a loop are both common.