When you first encounter Rust's strings, you might think, "Why is this so complicated?" You can't get the first character like in other languages with s[0], and String and &str can be confusing too. The reason is clear — Rust strings are always UTF-8, and a single "character" can take 1 to 4 bytes. In this article, we summarize that principle and common handling patterns.
Learning Objectives
- Understand the memory model of
Stringand&str. - Choose the appropriate method among 4 string concatenation types (
push_str/push/+/format!) depending on the situation. - Know the difference between UTF-8 byte length and the number of Unicode characters.
- Understand why string indexing is restricted and the safe traversal methods (
chars()/bytes()).
Key Concepts
1) String vs &str
| Type | Own? | Mutable? | Memory |
|---|---|---|---|
String |
O | O (possible) | Data on the heap, (ptr, len, cap) on the stack |
&str |
X | X | (ptr, len) pointing to UTF-8 bytes elsewhere |
&'static str |
X | X | Static data in the binary |
For function parameters, &str is almost always the correct choice — it’s compatible with String, &String, and string literals.
2) Four Ways of Combining Strings
| Method | Code | Remarks |
|---|---|---|
push_str |
s.push_str("..."); |
Attach slice |
push |
s.push('!'); |
One character |
+ |
let c = a + &b; |
Left-hand side String move, right-hand side &str |
format! |
let c = format!("{a}{b}"); |
New String, retain argument ownership |
3) The Pitfalls of UTF-8
let s = "Han"; //3 bytes in UTF-8
s.len(); //3 (bytes)
s.chars().count(); //1 (Unicode scalar)
len() is always the number of bytes. If you need the number of characters, use chars().count(). If you need a graphic grapheme (e.g., combined emoji) unit, use the external crate unicode-segmentation.
4) Why is it not being indexed?
If possible, a user might try to take only 1 byte, but that may not be a valid UTF-8 character. Rust enforces handling only valid UTF-8 at all times, so direct indexing is prohibited, and only explicit methods (chars(), bytes(), slicing) are allowed.
5) Slicing
&s[a..b] is in byte units. If you cut in the middle of a multibyte character, it causes a runtime panic. Safely, you identify the character boundary with s.char_indices() and then cut.
Examples
Example 1 — ex01_combine.rs: Coupling Method
//Example 1 of Chapter 10: Methods of String Concatenation
//
//push_str / push / + operator / format! macro
fn main() {
//Appending directly to a mutable String
let mut greeting = String::from("Hello");
greeting.push_str(", "); //Adding string slices
greeting.push('R'); //Single character (char)
greeting.push_str("ust!");
println!("Push series: {greeting}");
//+ operator — left side owns the String, right side is &str
let a = String::from("Hello,");
let b = String::from("World!");
let ab = a + &b; //a is moved, b is borrowed
println!("+ operator: {ab}");
//format! — creates a new String and takes no ownership of any argument
let name = String::from("Rust");
let lang = String::from("Korean");
let sentence = format!("I am taking the {} lecture in {}.", name, lang);
println!("format!: {sentence}");
println!("(Can be used as is) name={name}, lang={lang}");
}
Example 2 — ex02_iterate.rs: Iteration and Indexing
//Example 2 of Chapter 10: String Iteration and Indexing
//
//Strings are UTF-8 variable-length encoded, so direct indexing is blocked.
//Instead, use `chars()` (Unicode scalars) and `bytes()` (raw bytes).
fn main() {
let s = String::from("Rust one");
//Indexing like s[0] causes a compile error
// let c = s[0]; // ❌
//Iteration by character (char)
print!("chars: ");
for c in s.chars() {
print!("{c} ");
}
println!();
//Iteration by byte (UTF-8 raw bytes)
print!("bytes: ");
for b in s.bytes() {
print!("{b} ");
}
println!();
println!("byte len = {}", s.len());
println!("char count = {}", s.chars().count());
//Safe slicing: only use when you know character boundaries
//Get only the 4 bytes of "Rust"
let head = &s[..4];
println!("head = {head}");
}
Example 3 — ex03_owned_vs_borrowed.rs: Conversion of Ownership and Borrowing
//Example 3 of Chapter 10: Converting String (ownership) ↔ &str (borrowing)
fn describe(s: &str) -> String {
format!("The length of input "{s}" is {} bytes", s.len())
}
fn main() {
let owned: String = String::from("Free");
let lit: &str = "Literal";
//&String → &str is automatic via deref
println!("{}", describe(&owned));
println!("{}", describe(lit));
//&str → String (creating an owned copy)
let copied: String = lit.to_string();
let copied2: String = String::from(lit);
println!("copied = {copied}, copied2 = {copied2}");
//String → &str (creating a slice)
let borrowed: &str = owned.as_str();
println!("borrowed = {borrowed}");
}
Common Mistakes
Q. It is s.len() == 3, but only one character is visible on the screen.
A. len() is the number of UTF-8 bytes. A single Korean character usually takes 3 bytes, which is why it appears that way. If you need the number of characters, use s.chars().count().
Q. &s[0..2] causes a panic.
A. String slicing operates at the byte level, and it panics because it was cut at a point that is not a UTF-8 character boundary. To safely slice, use a pattern like s.char_indices().nth(N).map(|(i, _)| &s[..i]).
Q. Is there a difference between String::new() and String::from("")?
A. The result is the same, an empty String. The difference is just in the way of expressing intent — use new() for an empty buffer you plan to fill soon, and from for literal-based.
Q. Which is better, + or format!?
A. If you are combining 2–3 pieces and it's okay to transfer the ownership of the left-hand String, + is faster. Otherwise, format! is safer and more readable.
Summary
Stringis owned and mutable,&stris borrowed. Function parameters are usually&str.- For combining, use
push_str/push/+/format!according to the situation. len()counts bytes,chars().count()counts characters.- Direct indexing is prohibited, slicing is at the byte level, so be careful with character boundaries.
Try it yourself
Solve the assignment below. The reference solution is shown below.