Rust: A Comprehensive Guide to Rust Strings
Last updated: 2026-08-26
Rust has not one, but two string types—because system programming requires precise control over memory.
String handling in Rust is a source of confusion for many beginners: Why are there two types—String and &str? How do they differ from char* in C? This lesson will help you fully understand it.
1. What You'll Learn
- The difference between string literals
&strand heap-allocated stringsString - Creating, Appending, Concatenating, and Formatting
String - String Slicing and Indexed Access
- Common string methods (len, contains, replace, trim)
- The Impact of UTF-8 Encoding on String Operations
2. The Story of a Librarian
(1) Problem: The JSON data is too long to fit
Maria is a librarian who is developing a small book search system to manage 5,000 books.
- The length of each book's title varies; the longest is 200 characters
- At first, she used fixed-length strings to store book titles (such as
char[50]in C). - As it turns out, "One Hundred Years of Solitude" fits, but "How to Make a Complete Map of an Infinite Universe" is truncated.
- After switching to
char*, I ran into another memory release issue—I forgot to callfreein one place, and the system crashed.
"It would be so great if there were strings that automatically managed memory..." She later found the answer in Rust.
(2) Rust's String Solution
fn main() {
// &str: String Literals, Present in the binary at compile time
let title_book1: &str = "One Hundred Years of Solitude";
// String: Allocated on the heap, Variable length, Automatic Memory Management
let mut title_book2 = String::from("How to Make a Complete Map of an Infinite Universe");
title_book2.push_str(" (Second Edition)"); // Additional Content
println!("Book 1: {} (Length: {} Bytes)", title_book1, title_book1.len());
println!("Book 2: {} (Length: {} Bytes)", title_book2, title_book2.len());
// String Automatically released when leaving the scope, No manual free required
}
Rust's
Stringis like Java's strings—it automatically manages memory;&stris like C'sconst char*—read-only references. Together, they strike a balance between performance and flexibility.
3. The Difference Between &str and String
(1) Memory Model
graph LR
subgraph "&str (String Literals)"
S1_ptr[ptr ──→ ...rust...]
S1_len[len: 4]
end
subgraph "String (Heap Allocation)"
S2_ptr[ptr ──→ Rust is...]
S2_len[len: 10]
S2_cap[cap: 16]
end
subgraph "Storage Location"
STACK[Stack: &str<br>Read-Only Borrow]
HEAP[Heap: String<br>Ownership]
end
S1_ptr -.->|Determined at compile time| STACK
S2_ptr -.->|Runtime Allocation| HEAP
| Characteristics | &str |
String |
|---|---|---|
| Owner | Read-only access | Full ownership |
| Mutability | Immutable | Can be appended/modified |
| Storage Location | Read-only data segment (at compile time) | Heap (allocated at runtime) |
| Creation Method | Literal "hello" |
String::from() or .to_string() |
| Use Cases | Read-only string parameters | Need to modify or take ownership of the string |
(2) Quick Reference for Common String Methods
| Method | Return Type | Description | Example |
|---|---|---|---|
push_str(&str) |
() |
Additional character string slice | s.push_str("abc") |
push(char) |
() |
Add a single character | s.push('!') |
len() |
usize |
Return Byte Length | s.len() |
is_empty() |
bool |
Is it empty? | s.is_empty() |
trim() |
&str |
Trim leading and trailing spaces | s.trim() |
contains(&str) |
bool |
Whether the substring is included | s.contains("rust") |
replace(&str, &str) |
String |
Replace all matches | s.replace("a", "b") |
| Split by delimiter | split(char) |
Split |
s.split(',') |
| Convert to lowercase | |||
| Upper case | to_uppercase() |
String |
Switch to uppercase |
chars() |
Chars |
Iterate by character | s.chars() |
bytes() |
Bytes |
Iterate by bytes | s.bytes() |
| Clear string | |||
capacity() |
usize |
Return to Buffer Capacity | s.capacity() |
(3) Creation Method
// &str Create
let s1: &str = "hello, world"; // String Literals
let s2: &str = r"raw string\n"; // Original string(Do not escape)
// String Create
let s3 = String::from("hello");
let s4 = "world".to_string();
let s5 = format!("{}-{}", s3, s4); // Format and Create
let s6: String = "hi".into(); // Through Type Conversion
(4) Quick Reference for String Conversion Methods
| Source Type | Target Type | Conversion Method | Zero-Overhead |
|---|---|---|---|
| Allocate heap memory | |||
| Allocate heap memory | |||
| Allocate heap memory | |||
String |
&str |
&s or s.as_str() |
Zero-cost (borrow) |
String |
&str |
&s[..] |
Zero-cost (slice) |
&str |
&[u8] |
"hello".as_bytes() |
Zero-cost (borrowing) |
String |
Vec<u8> |
s.into_bytes() |
Zero Overhead (Transfer of Ownership) |
Vec<u8> |
String |
String::from_utf8(v) |
Zero overhead (transfer of ownership; UTF-8 verification required) |
4. Common String Operations
▶ Example 1: Creating and Modifying Strings (Difficulty ⭐)
Output:
After the addition: Hello
After concatenation: Hello, World!
format Results: tic-tac-toe
// ============================================
// String Creation, Append and Concatenation
// ============================================
fn main() {
// Create
let mut s = String::from("Hello");
// Add
s.push_str(", "); // Append a string slice
s.push('R'); // Add a single character
s.push_str("ust!");
println!("After the addition: {}", s);
// Concatenation (+ operator consumes the left-hand side String)
let s1 = String::from("Hello, ");
let s2 = String::from("World!");
let s3 = s1 + &s2; // s1 Moved, It can't be used anymore.
println!("After concatenation: {}", s3);
// format! macro (Does not consume any variables)
let a = String::from("tic");
let b = String::from("tac");
let c = String::from("toe");
let result = format!("{}-{}-{}", a, b, c);
println!("format Results: {}", result);
}
Output:
Byte Length: 9
First 4 Byte Slice: <slice1>
<c>
<b>
The
+operator consumes (moves) theStringon its left, sos1can no longer be used after concatenation.format!does not consume any variables; ownership of all variables is retained.
▶ Example 2: String Slicing and Indexing (Difficulty ⭐⭐)
Output:
Byte Length: 9
First 4 Byte Slice: <slice1>
<c>
<b>
// ============================================
// String Slicing -- Note UTF-8 Boundary!
// ============================================
fn main() {
let s = String::from("Rust🦀Code");
// Get the length of a string (Number of bytes, Not the number of characters)
println!("Byte Length: {}", s.len()); // 11 (4 Byte ASCII + 4 Byte UTF-8 emoji + 4 Byte ASCII)
// String Slicing (Index by Byte, Must be at a character boundary)
let slice1 = &s[0..4]; // "Rust" (first 4 bytes fall exactly on a character boundary)
println!("First 4 Byte Slice: {}", slice1);
// &s[0..5] will panic! (Because bytes 4-5 are in the middle of the emoji)
// Iterate by character count
for c in s.chars() {
print!("{} ", c);
}
println!();
// Iterate by byte
for b in s.bytes() {
print!("{:02x} ", b);
}
println!();
}
Output:
Byte Length: 11
First 4 Byte Slice: Rust
R u s t 🦀 C o d e
52 75 73 74 f0 9f a6 80 43 6f 64 65
Rust strings do not support direct index-based access (such as
s[0]) because UTF-8 characters are variable-length. You must iterate through the characters using.chars()or use byte slices. Slices must align with character boundaries; otherwise, a runtime crash will occur.
▶ Example 3: Common String Methods (Difficulty ⭐⭐)
Output:
trim: 'Hello, Rust World!'
contains 'Rust': true
replace: ' Hello, Go World! '
split: <parts>
lower: hello, upper: WORLD
// ============================================
// Common String Queries, Replace, Cutting Methods
// ============================================
fn main() {
let s = " Hello, Rust World! ";
// Remove leading and trailing spaces
println!("trim: '{}'", s.trim());
// Includes a check
println!("contains 'Rust': {}", s.contains("Rust"));
// Replace
let replaced = s.replace("Rust", "Go");
println!("replace: '{}'", replaced);
// Split
let parts: Vec<&str> = "apple,banana,cherry".split(',').collect();
println!("split: {:?}", parts);
// Case Conversion
let lower = "HELLO".to_lowercase();
let upper = "world".to_uppercase();
println!("lower: {}, upper: {}", lower, upper);
}
Output:
String: Rust🦀Code
Byte Length: 9
Number of characters: 9
--- Character-by-character traversal ---
Character <i>: '<c>' (Unicode: U+<c as u32>)
--- Traverse byte by byte ---
<b>
trim()andsplit()return&strwithout creating a new string.replace()returns a brand-newString. Consider the performance overhead when making modifications.
▶ Example 4: Iterating Through UTF-8 Strings and Boundary Checks (Difficulty ⭐⭐⭐)
Output:
String: Rust🦀Code
Byte Length: 9
Number of characters: 9
--- Character-by-character traversal ---
Character <i>: '<c>' (Unicode: U+<c as u32>)
--- Traverse byte by byte ---
<b>
--- Safe Slices ---
ASCII Part &s[0..4]: '<ascii_part>'
Emoji Part &s[4..8]: '<emoji_part>'
Code Part &s[8..12]: '<code_part>'
Concatenated Reconstruction: <combined>
// ============================================
// UTF-8 Bytes in a string, Character and Grapheme Clusters
// ============================================
fn main() {
let s = "Rust🦀Code";
println!("String: {}", s);
println!("Byte Length: {}", s.len());
println!("Number of characters: {}", s.chars().count());
println!("\n--- Character-by-character traversal ---");
for (i, c) in s.chars().enumerate() {
println!("Character {}: '{}' (Unicode: U+{:04X})", i, c, c as u32);
}
println!("\n--- Traverse byte by byte ---");
for (i, b) in s.bytes().enumerate() {
print!("{:02x} ", b);
if (i + 1) % 8 == 0 { println!(); }
}
println!();
println!("\n--- Safe Slices ---");
let ascii_part = &s[0..4];
println!("ASCII Part &s[0..4]: '{}'", ascii_part);
let emoji_start = 4;
let emoji_end = emoji_start + 4;
let emoji_part = &s[emoji_start..emoji_end];
println!("Emoji Part &s[{}..{}]: '{}'", emoji_start, emoji_end, emoji_part);
let code_start = emoji_end;
let code_end = code_start + 4;
let code_part = &s[code_start..code_end];
println!("Code Part &s[{}..{}]: '{}'", code_start, code_end, code_part);
let combined = format!("{}{}{}", ascii_part, emoji_part, code_part);
println!("Concatenated Reconstruction: {}", combined);
}
Output:
String: Rust🦀Code
Byte Length: 12
Number of characters: 8
--- Character-by-character traversal ---
Character 0: 'R' (Unicode: U+0052)
Character 1: 'u' (Unicode: U+0075)
Character 2: 's' (Unicode: U+0073)
Character 3: 't' (Unicode: U+0074)
Character 4: '🦀' (Unicode: U+1F980)
Character 5: 'C' (Unicode: U+0043)
Character 6: 'o' (Unicode: U+006F)
Character 7: 'd' (Unicode: U+0064)
Character 8: 'e' (Unicode: U+0065)
--- Traverse byte by byte ---
52 75 73 74 f0 9f a6 80 43 6f 64 65
--- Safe Slices ---
ASCII Part &s[0..4]: 'Rust'
Emoji Part &s[4..8]: '🦀'
Code Part &s[8..12]: 'Code'
Concatenated Reconstruction: Rust🦀Code
In UTF-8, ASCII characters take up 1 byte and emojis take up 4 bytes. Slicing must occur at character boundaries; otherwise, a panic will occur at runtime. Use
.chars().enumerate()to safely process characters one by one.
▶ Example 5: Practical String Parsing and Formatting (Difficulty: ⭐⭐)
Output:
Raw Data: <raw_data>
--- Parsing Key-Value Pairs ---
<key> => <value>
--- Construct a New String ---
Abstract: Alice, 30 years old, From Beijing
Personnel Report
// ============================================
// String Parsing, Combined Use of Formatting and Linking
// ============================================
fn main() {
let raw_data = "name=Alice;age=30;city=Beijing";
println!("Raw Data: {}", raw_data);
println!("\n--- Parsing Key-Value Pairs ---");
let pairs: Vec<&str> = raw_data.split(';').collect();
for pair in pairs {
let parts: Vec<&str> = pair.split('=').collect();
if parts.len() == 2 {
let key = parts[0].trim();
let value = parts[1].trim();
println!("{} => {}", key, value);
}
}
println!("\n--- Construct a New String ---");
let name = "Alice";
let age = 30;
let city = "Beijing";
let summary = format!("{}, {} years old, From {}", name, age, city);
println!("Abstract: {}", summary);
let mut report = String::from("Personnel Report\n");
report.push_str(&format!("Name: {}\n", name));
report.push_str(&format!("Age: {}\n", age));
report.push_str(&format!("City: {}\n", city));
report.push_str("--- End ---");
println!("\n{}", report);
let csv_line = ["Alice", "30", "Beijing"].join(",");
println!("\nCSV Format: {}", csv_line);
}
Output:
Raw Data: name=Alice;age=30;city=Beijing
--- Parsing Key-Value Pairs ---
name => Alice
age => 30
city => Beijing
--- Construct a New String ---
Abstract: Alice, 30 years old, From Beijing
Personnel Report
Name: Alice
Age: 30
City: Beijing
--- End ---
CSV Format: Alice,30,Beijing
split()+collect()are the basic patterns for parsing structured text;format!is suitable for one-time concatenation;push_str()is suitable for gradually constructing long texts;join()is suitable for connecting arrays with delimiters.
❓ FAQ
&str is a read-only borrow (zero overhead), while String is an owned mutable string (with heap allocation overhead). Using &str for function parameters offers the most flexibility, since String can be automatically converted to &str..to_string() or String::from(); to convert String to &str, use &s or s.as_str(). All conversions are zero-cost (they simply represent memory from a different perspective).s[0] accesses the first byte, which is not necessarily a complete character. Rust is designed to be safer: it would rather throw a compile-time error than allow unexpected behavior at runtime.+ consumes the String on the left and reuses its buffer (for high performance), while format! does not consume the variable but creates a brand-new String. Use + for two or three segments, and format! for more than three.String can be modified (after declaring mut, you can use methods such as .push_str(), .push(), .insert(), etc.). &str is a read-only reference; the data it points to cannot be modified. If you need to modify a string within a function, the parameter type should be &mut String.📖 Summary
- Rust has two types of strings:
&str(read-only borrow) andString(ownership) Stringis a heap-allocated, resizable UTF-8 string that is automatically deallocated when it goes out of scope&stris a string slice that points to a contiguous sequence of valid UTF-8 bytes- Due to UTF-8 encoding, strings do not support direct access by index; you must use
.chars()or byte slices. - Common methods:
.len(),.trim(),.contains(),.replace(),.split() - When function parameters accept strings, use
&strwhenever possible to ensure maximum flexibility
📝 Exercises
- Difficulty ⭐: Create a
String, then call the.push_str(),.push(),+, andformat!operators in sequence, and compare the results of the four approaches. - Difficulty ⭐⭐: Write a function
fn first_word(s: &str) -> &strthat returns the first word in a string (separated by spaces). Call it using&strandString, respectively. - Difficulty ⭐⭐⭐: Explore UTF-8 boundary issues—create a string containing the emoji 🦀
"Rust🦀", try slicing it with&s[0..5]and&s[0..6], and observe which one succeeds and which one crashes.