Rust 30 🦀 UTF-8 String Storage (String vs &str)
Rust has two string types, and the distinction between them is one of the first things a new Rust programmer has to internalize. A String is a growable, heap-allocated, owned sequence of UTF-8 bytes. A &str is a borrowed view into a sequence of UTF-8 bytes, usually a slice of a String or a literal embedded in the binary. They are not interchangeable, and the compiler does not convert between them implicitly. Understanding why requires understanding ownership, borrowing, and how Rust represents text.
This chapter covers String and &str in full. You will learn how each is stored, how they are created, how they are converted, and how the UTF-8 encoding affects indexing, slicing, and iteration. You will also see the patterns that make string handling ergonomic and the pitfalls that produce borrow checker errors, panics on non-ASCII input, and unnecessary allocations.
Key point: A String owns its data. A &str borrows it. A string literal in source code has type &'static str—a borrowed slice that lives for the entire program. A function that needs to read a string should take &str. A function that needs to build or modify a string should take or return String. The Deref coercion from &String to &str means that a &String can be passed where a &str is expected.
Why two string types exist
The ownership problem. Text in a program can be owned or borrowed. A string created at runtime—read from a file, concatenated from parts, received from a network—needs to be owned by something. A string that is a slice of another string, or a literal in the binary, does not need to be owned. Rust distinguishes the two cases at the type level. String is the owned type. &str is the borrowed type.
The UTF-8 problem. Rust strings are always valid UTF-8. This is enforced by the type system: a String cannot contain invalid UTF-8, and a &str always points to valid UTF-8. This guarantee is what makes string operations safe without runtime checks. But it also means that a string is a sequence of bytes, and the mapping from bytes to characters is not one-to-one. A character can be one to four bytes. Indexing by byte offset is not the same as indexing by character.
The growability problem. A String can grow. It can be pushed to, concatenated, and replaced. A &str cannot. It is a fixed view. Operations that produce a new string return a String; operations that produce a view return a &str.
The literal problem. A string literal in source code, like "hello", is embedded in the compiled binary. Its type is &'static str, a reference to that embedded data. It is not a String because there is no allocation and no ownership. If a String is needed, the literal is converted with .to_string() or String::from.
The efficiency problem. Passing a &str to a function avoids copying or allocating. Passing a String by value transfers ownership, which is sometimes needed and sometimes not. Most functions that read a string should accept &str, so they work with both literals and owned strings. The Deref coercion from &String to &str makes this ergonomic.
a. Creating and converting strings
A String is created in several ways.
let s1 = String::new(); // empty
let s2 = String::from("hello"); // from a literal
let s3 = "hello".to_string(); // from a literal
let s4 = "hello".to_owned(); // from a literal
let s5 = String::with_capacity(100); // pre-allocated
String::new creates an empty string with no allocation. String::from and .to_string() create a string from a &str by copying the bytes. .to_owned() is equivalent to .to_string() and is often used in generic contexts. String::with_capacity pre-allocates space for a known number of bytes.
A &str is created from a literal or borrowed from a String.
let literal: &str = "hello";
let owned = String::from("world");
let borrowed: &str = &owned;
let slice: &str = &owned[0..3]; // "wor"
The &owned expression coerces from &String to &str. The &owned[0..3] slices the string by byte range and returns a &str.
Conversion between the types:
| From | To | Method |
|---|---|---|
&str | String | .to_string() |
&str | String | String::from |
&str | String | .to_owned() |
String | &str | &s or s.as_str() |
String | &str | s.as_ref() |
let borrowed = "hello";
let owned = borrowed.to_string();
let owned2 = String::from("world");
let borrowed2 = owned2.as_str();
The .as_str() method is explicit and returns a &str. The &owned2 expression is equivalent and relies on Deref coercion.
A String can be converted to a &str and back, but the round trip does not allocate if the &str is already owned. The String::from and .to_string() methods allocate and copy. The into method can convert between types when a conversion is defined.
b. UTF-8, indexing, and iteration
A Rust string is a sequence of bytes. The len() method returns the number of bytes, not the number of characters.
let s = "hello";
println!("{}", s.len()); // 5
let emoji = "😀";
println!("{}", emoji.len()); // 4
The emoji is a single character but four bytes in UTF-8. The length is the byte count.
The chars() method returns an iterator over the Unicode scalar values.
let s = "héllo";
for c in s.chars() {
println!("{}", c);
}
// h, é, l, l, o
The bytes() method returns an iterator over the raw bytes.
let s = "héllo";
for b in s.bytes() {
println!("{}", b);
}
// 104, 195, 169, 108, 108, 111
The é is two bytes: 195 and 169.
Indexing a string with s[i] is not allowed. The index would have to be a byte offset, and the compiler cannot guarantee that the offset falls on a character boundary. Instead, string slicing with a range is allowed, but it panics if the range does not fall on character boundaries.
let s = "hello";
let slice = &s[0..2]; // "he"
let emoji = "😀";
// let bad = &emoji[0..1]; // panics: byte index 1 is not a char boundary
The fix is to iterate characters and build the slice from character offsets, or to use the get method which returns Option<&str>.
let s = "hello";
if let Some(slice) = s.get(0..2) {
println!("{}", slice); // "he"
}
let emoji = "😀";
if let Some(slice) = emoji.get(0..1) {
println!("{}", slice); // None, because 1 is not a boundary
}
The char_indices method returns an iterator over byte offsets and characters, which is useful for building slices safely.
let s = "héllo";
for (i, c) in s.char_indices() {
println!("{}: {}", i, c);
}
// 0: h
// 1: é
// 3: l
// 4: l
// 5: o
The é starts at byte 1 and occupies two bytes, so the next character starts at byte 3.
c. Common operations and ownership patterns
Appending to a String uses push for a single character and push_str for a string slice.
let mut s = String::from("hello");
s.push(' ');
s.push_str("world");
println!("{}", s); // "hello world"
The push method takes a char, and push_str takes a &str. Neither returns a value; they modify the string in place.
Concatenation with + consumes the left operand:
let s1 = String::from("hello");
let s2 = String::from("world");
let s3 = s1 + " " + &s2;
// s1 is moved, s2 is borrowed
The + operator calls the add method, which takes self by value and &str by reference. This means the left operand is consumed, and the right operand is borrowed. The format! macro is the alternative that does not consume its arguments:
let s1 = String::from("hello");
let s2 = String::from("world");
let s3 = format!("{} {}", s1, s2);
// s1 and s2 are still valid
The format! macro is preferred when the operands should not be consumed or when the concatenation involves more than two parts.
Formatting and parsing:
let n = 42;
let s = format!("Number: {}", n);
let parsed: i32 = "42".parse().unwrap();
let parsed_with_type = "42".parse::<i32>().unwrap();
The parse method converts a string to another type. It returns a Result because the conversion can fail. The type must be known, either from the context or from a turbofish annotation.
Trimming, splitting, and replacing:
let s = " hello world ";
println!("{}", s.trim()); // "hello world"
println!("{}", s.trim_start()); // "hello world "
println!("{}", s.trim_end()); // " hello world"
let csv = "a,b,c";
for part in csv.split(',') {
println!("{}", part);
}
let replaced = "hello".replace("l", "L");
println!("{}", replaced); // "heLLo"
The split method returns an iterator of &str. The replace method returns a new String.
Searching:
let s = "hello world";
println!("{}", s.contains("world")); // true
println!("{}", s.starts_with("hello")); // true
println!("{}", s.ends_with("world")); // true
println!("{:?}", s.find("world")); // Some(6)
The find method returns the byte index of the first occurrence, or None.
Case conversion:
let s = "Hello";
println!("{}", s.to_uppercase()); // "HELLO"
println!("{}", s.to_lowercase()); // "hello"
The to_uppercase and to_lowercase methods return new String values because case conversion can change the number of bytes.
Slicing with character safety:
let s = "hello world";
let first_word = s.split_whitespace().next().unwrap_or("");
println!("{}", first_word); // "hello"
The split_whitespace method returns an iterator of words, and next returns the first one as Option<&str>.
Complete Example Session
// ============================================
// PART 1: CREATING STRINGS
// ============================================
let s1 = String::new();
let s2 = String::from("hello");
let s3 = "hello".to_string();
let s4 = String::with_capacity(100);
// ============================================
// PART 2: STRING VS &STR
// ============================================
let owned: String = String::from("world");
let borrowed: &str = &owned;
let literal: &str = "hello";
// ============================================
// PART 3: CONVERSION
// ============================================
let s: String = "hello".to_string();
let b: &str = s.as_str();
let s2: String = String::from(b);
// ============================================
// PART 4: BYTE LENGTH VS CHARACTERS
// ============================================
let ascii = "hello";
let emoji = "😀";
println!("{}", ascii.len()); // 5
println!("{}", emoji.len()); // 4
println!("{}", emoji.chars().count()); // 1
// ============================================
// PART 5: ITERATION
// ============================================
let s = "héllo";
for c in s.chars() {
println!("{}", c);
}
for b in s.bytes() {
println!("{}", b);
}
// ============================================
// PART 6: SAFE SLICING
// ============================================
let s = "hello";
if let Some(slice) = s.get(0..2) {
println!("{}", slice); // "he"
}
let emoji = "😀";
if let Some(slice) = emoji.get(0..1) {
println!("{}", slice); // None
}
// ============================================
// PART 7: APPENDING
// ============================================
let mut s = String::from("hello");
s.push(' ');
s.push_str("world");
println!("{}", s); // "hello world"
// ============================================
// PART 8: CONCATENATION
// ============================================
let s1 = String::from("hello");
let s2 = String::from("world");
let s3 = format!("{} {}", s1, s2);
// s1 and s2 still valid
// ============================================
// PART 9: SPLIT AND TRIM
// ============================================
let csv = " a,b,c ";
for part in csv.trim().split(',') {
println!("{}", part);
}
// ============================================
// PART 10: PARSE
// ============================================
let n: i32 = "42".parse().unwrap();
let f = "3.14".parse::<f64>().unwrap();
println!("{} {}", n, f);
The ten parts covered creating strings, String versus &str, conversion, byte length versus characters, iteration, safe slicing, appending, concatenation, split and trim, and parsing.
Quick Reference
String Types
| Type | Owns | Growable | Storage |
|---|---|---|---|
String | Yes | Yes | Heap |
&str | No | No | Borrowed |
&'static str | No | No | Binary |
Creation
| Method | Result |
|---|---|
String::new() | Empty String |
String::from("x") | String from &str |
"x".to_string() | String from &str |
"x".to_owned() | String from &str |
String::with_capacity(n) | Pre-allocated String |
"literal" | &'static str |
Conversion
| From | To | Method |
|---|---|---|
&str | String | .to_string(), String::from |
String | &str | &s, s.as_str() |
String | &[u8] | s.as_bytes() |
Common Methods
| Method | Purpose |
|---|---|
push(c) | Append a char |
push_str(s) | Append a &str |
len() | Byte length |
chars() | Iterator over char |
bytes() | Iterator over u8 |
trim() | Remove surrounding whitespace |
split(sep) | Split by separator |
replace(a, b) | Replace occurrences |
contains(s) | Check substring |
starts_with(s) | Check prefix |
ends_with(s) | Check suffix |
find(s) | Find byte index |
to_uppercase() | Uppercase |
to_lowercase() | Lowercase |
parse() | Convert to another type |
Function Signatures
| Signature | Use |
|---|---|
fn f(s: &str) | Read a string |
fn f(s: String) | Take ownership |
fn f() -> String | Return an owned string |
fn f() -> &str | Return a borrowed string |
Best Practices
✅ Do This:
// Use &str for function parameters
fn greet(name: &str) { println!("Hello, {}", name); } // ✅
// Use String for owned data
fn create_greeting(name: &str) -> String {
format!("Hello, {}", name)
} // ✅
// Use format! for concatenation
let s = format!("{} {}", a, b); // ✅
// Use chars() for character iteration
for c in s.chars() { } // ✅
// Use get for safe slicing
if let Some(slice) = s.get(0..2) { } // ✅
// Use as_str() for explicit conversion
let s: &str = owned.as_str(); // ✅
// Use with_capacity when building
let mut s = String::with_capacity(100); // ✅
❌ Don’t Do This:
// Don't index a string
let c = s[0]; // error // ❌
// Don't slice without checking boundaries
let slice = &s[0..2]; // may panic on non-ASCII // ⚠️
// Don't use &String for function parameters
fn f(s: &String) { } // use &str // ⚠️
// Don't use + when format! is clearer
let s = a + " " + &b + " " + &c; // verbose // ⚠️
// Don't assume len() is character count
let n = "😀".len(); // 4, not 1 // ⚠️
// Don't clone unnecessarily
fn f(s: String) { } // takes ownership // ⚠️
// Don't forget that + consumes the left operand
let s = a + &b; // a is moved // ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Cannot index string | UTF-8 variable length | Use chars() or bytes() |
| Slice panics | Not on char boundary | Use get |
len() is bytes | UTF-8 encoding | Use chars().count() |
+ moves left operand | add takes self | Use format! |
| Borrow error | String moved | Use &str or clone |
| Extra allocation | .to_string() on a literal | Pass &str |
&String in signature | Less general | Use &str |
Real-World Examples
1. Function Taking &str
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
2. Concatenation
let full = format!("{} {}", first, last);
3. Iteration
for c in text.chars() {
println!("{}", c);
}
4. Split
for word in sentence.split_whitespace() {
println!("{}", word);
}
5. Trim and Parse
let n: i32 = input.trim().parse().unwrap();
6. Replace
let cleaned = raw.replace('\n', " ");
7. Safe Slice
if let Some(prefix) = s.get(0..10) {
println!("{}", prefix);
}
8. Contains
if email.contains('@') { }
9. Starts With
if path.starts_with("/api") { }
10. Build with Capacity
let mut result = String::with_capacity(100);
result.push_str("Hello");
Visual
String vs &str
┌─────────────────────────────────────────────────────────────┐
│ String │
│ ┌─────────┐ │
│ │ ptr │ ──▶ ┌─────┬─────┬─────┬─────┬─────┐ │
│ │ len: 5 │ │ h │ e │ l │ l │ o │ │
│ │ cap: 8 │ └─────┴─────┴─────┴─────┴─────┘ │
│ └─────────┘ Owns the heap allocation. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ &str │
│ ┌─────────┐ │
│ │ ptr │ ──▶ "hello" (borrowed, could be anywhere) │
│ │ len: 5 │ │
│ └─────────┘ │
│ │
│ Borrows a slice of UTF-8 bytes. Does not own. │
│ │
└─────────────────────────────────────────────────────────────┘
UTF-8 Byte Layout
┌─────────────────────────────────────────────────────────────┐
│ "héllo" │
│ │
│ h é l l o │
│ ┌───┐ ┌───┬───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │104│ │195│169│ │108│ │108│ │111│ │
│ └───┘ └───┴───┘ └───┘ └───┘ └───┘ │
│ byte 0 bytes 1-2 byte 3 byte 4 byte 5 │
│ │
│ len() = 6 bytes │
│ chars().count() = 5 characters │
│ │
│ Slicing &s[0..1] gives "h" │
│ Slicing &s[1..2] panics: byte 2 is not a char boundary │
│ │
└─────────────────────────────────────────────────────────────┘
Iteration Types
┌─────────────────────────────────────────────────────────────┐
│ s.chars() │
│ Yields char (Unicode scalar values) │
│ "héllo" → 'h', 'é', 'l', 'l', 'o' │
│ │
│ s.bytes() │
│ Yields u8 (raw bytes) │
│ "héllo" → 104, 195, 169, 108, 108, 111 │
│ │
│ s.char_indices() │
│ Yields (byte_index, char) │
│ "héllo" → (0,'h'), (1,'é'), (3,'l'), (4,'l'), (5,'o') │
│ │
└─────────────────────────────────────────────────────────────┘
Function Parameter Decision
┌─────────────────────────────────────────────────────────────┐
│ Does the function need to own the string? │
│ │ │
│ ├── YES ──▶ Take `String` │
│ │ (store it, return it, mutate it) │
│ │ │
│ └── NO ──▶ Take `&str` │
│ (read it, format it, split it) │
│ │
│ `&str` accepts both `&String` (via Deref) and literals. │
│ `&String` accepts only `&String`. │
│ Prefer `&str` for maximum flexibility. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
String | Owned, growable, heap |
&str | Borrowed, fixed, slice |
&'static str | Literal, lives for program |
Create String | String::new, String::from, .to_string() |
Convert to &str | &s, s.as_str() |
| Byte length | s.len() |
| Character count | s.chars().count() |
| Indexing | Not allowed |
| Slicing | &s[a..b], panics on non-boundary |
| Safe slice | s.get(a..b) |
| Append | push, push_str |
| Concatenate | + (consumes left), format! |
| Iterate chars | s.chars() |
| Iterate bytes | s.bytes() |
| Function param | &str |
Key takeaways:
- A
Stringowns its data; a&strborrows it. TheStringis growable and heap-allocated. The&stris a fixed view into a sequence of UTF-8 bytes, which may be aString, a literal, or another slice. - Rust strings are always valid UTF-8. This guarantee is enforced by the type system. It means that a string is a sequence of bytes, and a character can be one to four bytes. The byte length is not the character count.
- Indexing a string is not allowed. The index would be a byte offset, and the compiler cannot guarantee that it falls on a character boundary. Use
chars(),bytes(), orchar_indices()to iterate, andgetfor safe slicing. - String slicing panics on non-boundaries.
&s[0..1]is safe if the first character is one byte, but panics if it is multi-byte. Uses.get(0..1)to get anOption<&str>instead. &stris the right parameter type for functions that read strings. It accepts both&String(viaDerefcoercion) and literals. Using&Stringin a signature is less general and forces callers to have aString.- The
+operator consumes the left operand.s1 + &s2movess1and borrowss2. Theformat!macro does not consume its arguments and is preferred when the operands should remain valid. - Use
String::with_capacitywhen building a large string. It avoids the reallocations that occur as the string grows. The same geometric growth model applies to strings as to vectors.
Remember: The distinction between String and &str is the distinction between owning and borrowing, applied to text. A String owns its bytes. A &str borrows them. The type system enforces UTF-8 validity, and the encoding means that byte offsets and character indices are not the same. Use &str for function parameters, String for owned data, and format! for concatenation. Iterate with chars() for characters and bytes() for bytes. Slice with get when the boundary is not guaranteed. The two types are not a complication; they are the mechanism that makes string handling in Rust safe without runtime checks.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!