| |

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:

FromToMethod
&strString.to_string()
&strStringString::from
&strString.to_owned()
String&str&s or s.as_str()
String&strs.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

TypeOwnsGrowableStorage
StringYesYesHeap
&strNoNoBorrowed
&'static strNoNoBinary

Creation

MethodResult
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

FromToMethod
&strString.to_string(), String::from
String&str&s, s.as_str()
String&[u8]s.as_bytes()

Common Methods

MethodPurpose
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

SignatureUse
fn f(s: &str)Read a string
fn f(s: String)Take ownership
fn f() -> StringReturn an owned string
fn f() -> &strReturn 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

PitfallWhy It HappensFix
Cannot index stringUTF-8 variable lengthUse chars() or bytes()
Slice panicsNot on char boundaryUse get
len() is bytesUTF-8 encodingUse chars().count()
+ moves left operandadd takes selfUse format!
Borrow errorString movedUse &str or clone
Extra allocation.to_string() on a literalPass &str
&String in signatureLess generalUse &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

ItemValue
StringOwned, growable, heap
&strBorrowed, fixed, slice
&'static strLiteral, lives for program
Create StringString::new, String::from, .to_string()
Convert to &str&s, s.as_str()
Byte lengths.len()
Character counts.chars().count()
IndexingNot allowed
Slicing&s[a..b], panics on non-boundary
Safe slices.get(a..b)
Appendpush, push_str
Concatenate+ (consumes left), format!
Iterate charss.chars()
Iterate bytess.bytes()
Function param&str

Key takeaways:

  • A String owns its data; a &str borrows it. The String is growable and heap-allocated. The &str is a fixed view into a sequence of UTF-8 bytes, which may be a String, 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(), or char_indices() to iterate, and get for 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. Use s.get(0..1) to get an Option<&str> instead.
  • &str is the right parameter type for functions that read strings. It accepts both &String (via Deref coercion) and literals. Using &String in a signature is less general and forces callers to have a String.
  • The + operator consumes the left operand. s1 + &s2 moves s1 and borrows s2. The format! macro does not consume its arguments and is preferred when the operands should remain valid.
  • Use String::with_capacity when 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!