Strings in Rust are one of the first things that confuse newcomers. Unlike most languages where strings are a single type, Rust has two primary string types with distinct ownership semantics. Once you understand why they exist, everything else clicks into place.
The Two String Types
| Type | Ownership | Mutability | Location | Size |
|---|---|---|---|---|
String |
Owned | Mutable | Heap | Dynamic |
&str |
Borrowed | Immutable | Anywhere | Fixed (fat pointer) |
Think of it this way: String is like a Vec<u8> — it owns its data, can grow, and lives on the heap. &str is like a &[u8] — a borrowed view into existing string data anywhere in memory.
&str — String Slices
A string slice is a reference to valid UTF-8 bytes with a known length. It doesn’t own the data — it points to it.
fn main() {
// String literal — stored in binary, lives for 'static lifetime
let s1: &'static str = "Hello, Rust!";
// Slice of a String
let owned = String::from("hello world");
let hello: &str = &owned[0..5]; // Borrows part of owned
let world: &str = &owned[6..]; // Rest of owned
println!("{} {}", hello, world); // hello world
// owned is still valid — we only borrowed it
println!("{}", owned);
}
Why &str in Function Parameters?
Accept &str instead of &String to make functions more flexible. A &String automatically coerces to &str (Deref coercion), so callers can pass either:
fn word_count(s: &str) -> usize {
s.split_whitespace().count()
}
fn main() {
let owned = String::from("the quick brown fox");
let literal = "jumps over the lazy dog";
println!("{}", word_count(&owned)); // &String → &str automatically
println!("{}", word_count(literal)); // &'static str directly
}
This is the idiomatic Rust pattern. A function taking &String only works for owned strings; &str works for both.
String — Owned UTF-8 Text
String owns heap-allocated, mutable, UTF-8-encoded text. It’s the go-to when you need to create, modify, or return string data.
Creating Strings
fn main() {
let s1 = String::new(); // Empty
let s2 = String::from("hello"); // From literal
let s3 = "hello".to_string(); // Same as above
let s4 = format!("Hello, {}!", "world"); // Formatted
let s5: String = "hello".chars().collect(); // From iterator
// From bytes (must be valid UTF-8)
let bytes = vec![104, 101, 108, 108, 111]; // "hello"
let s6 = String::from_utf8(bytes).unwrap();
println!("{}", s6); // hello
}
Modifying Strings
fn main() {
let mut s = String::from("Hello");
// Append
s.push(','); // Append single char
s.push_str(" world"); // Append string slice
println!("{}", s); // Hello, world
// Concatenation with + (takes ownership of left side)
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 is moved, s2 is borrowed
println!("{}", s3);
// s1 is gone; s2 is still valid
// format! — doesn't take ownership of anything
let a = String::from("tic");
let b = String::from("tac");
let c = String::from("toe");
let combined = format!("{a}-{b}-{c}");
println!("{combined}"); // a, b, c all still valid
// Replace
let s = String::from("I like cats and cats");
println!("{}", s.replace("cats", "dogs"));
println!("{}", s.replacen("cats", "dogs", 1)); // only first occurrence
// Case
println!("{}", "hello WORLD".to_uppercase()); // HELLO WORLD
println!("{}", "hello WORLD".to_lowercase()); // hello world
// Trim
let padded = " hello ";
println!("'{}'", padded.trim()); // 'hello'
println!("'{}'", padded.trim_start()); // 'hello '
}
UTF-8: The Important Details
Both String and &str are guaranteed to be valid UTF-8. This has consequences for indexing.
Why You Can’t Index by Integer
Characters in UTF-8 can be 1–4 bytes. Indexing s[0] is ambiguous: do you want the first byte or the first character? For multi-byte characters they’re different:
fn main() {
let s = String::from("Здравствуйте"); // Russian "Hello"
// let c = s[0]; // ERROR — cannot index String by integer
// let c = &s[0..1]; // PANIC — 'З' is 2 bytes, this splits a char!
// Correct: slice by byte boundary (you must know the char boundaries)
let first_two_chars = &s[0..4]; // 'З' is 2 bytes, 'д' is 2 bytes
println!("{}", first_two_chars); // Зд
}
Three Ways to View String Data
fn main() {
let s = "नमस्ते"; // Hindi "Namaste"
// 1. Bytes — raw memory representation
println!("Bytes: {}", s.bytes().count()); // 18 bytes
// 2. Unicode scalar values (chars)
println!("Chars: {}", s.chars().count()); // 6 chars
// 3. Grapheme clusters (visual units, requires unicode-segmentation crate)
// "स्" is two chars but one visual character (consonant + combining mark)
// Iterate over chars
for (i, c) in s.chars().enumerate() {
println!("{}: {}", i, c);
}
// nth char safely
let third = s.chars().nth(2);
println!("{:?}", third); // Some('स')
}
String Operations
Searching and Testing
fn main() {
let text = "The quick brown fox jumps over the lazy dog";
// Contains
println!("{}", text.contains("fox")); // true
println!("{}", text.starts_with("The")); // true
println!("{}", text.ends_with("dog")); // true
// Find (returns byte index)
println!("{:?}", text.find("fox")); // Some(16)
println!("{:?}", text.rfind("the")); // Some(31)
// Count occurrences
let count = text.matches("the").count();
println!("'the' appears {} times", count); // 1 (case sensitive)
let count_ci = text.to_lowercase().matches("the").count();
println!("'the' case-insensitive: {}", count_ci); // 2
// Is empty / length
let empty = "";
println!("{} {}", empty.is_empty(), empty.len());
}
Splitting
fn main() {
let csv = "alice,30,engineer";
let fields: Vec<&str> = csv.split(',').collect();
println!("{:?}", fields); // ["alice", "30", "engineer"]
// Split and limit
let s = "one two three four";
let two: Vec<&str> = s.splitn(2, ' ').collect();
println!("{:?}", two); // ["one", "two three four"]
// Split on multiple delimiters
let mixed = "hello world\tfoo\nbar";
let words: Vec<&str> = mixed.split_whitespace().collect();
println!("{:?}", words); // ["hello", "world", "foo", "bar"]
// Lines
let multiline = "line1\nline2\nline3";
for line in multiline.lines() {
println!("{}", line);
}
}
Parsing
Strings are the universal input format. Parse into any type that implements FromStr:
fn main() {
// Parse numbers
let n: i32 = "42".parse().unwrap();
let f: f64 = "3.14".parse().unwrap();
let b: bool = "true".parse().unwrap();
// Graceful error handling
match "abc".parse::<i32>() {
Ok(n) => println!("Parsed: {}", n),
Err(e) => println!("Failed: {}", e),
}
// Custom type parsing with FromStr
use std::str::FromStr;
#[derive(Debug)]
struct Point { x: f64, y: f64 }
impl FromStr for Point {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (x, y) = s.split_once(',').ok_or("missing comma")?;
Ok(Point {
x: x.trim().parse().map_err(|e| format!("{}", e))?,
y: y.trim().parse().map_err(|e| format!("{}", e))?,
})
}
}
let p: Point = "1.5, 2.7".parse().unwrap();
println!("{:?}", p); // Point { x: 1.5, y: 2.7 }
}
String Formatting
fn main() {
// Basic
let name = "Alice";
let age = 30;
println!("{} is {} years old", name, age);
// Named arguments
println!("{name} is {age} years old");
// Debug format
let v = vec![1, 2, 3];
println!("{:?}", v); // [1, 2, 3]
println!("{:#?}", v); // pretty-printed
// Number formatting
println!("{:08b}", 42); // 00101010 (binary, width 8, zero-padded)
println!("{:08x}", 255); // 000000ff (hex, width 8)
println!("{:.3}", 3.14159); // 3.142 (3 decimal places)
println!("{:>10}", "right"); // right-aligned, width 10
println!("{:<10}", "left"); // left-aligned
println!("{:^10}", "center"); // centered
// format! to String
let s: String = format!("{}_{}", "hello", 42);
println!("{}", s); // hello_42
}
Cow<'a, str> — Owned or Borrowed
Cow (Clone on Write) is a smart pointer that can hold either a borrowed &str or an owned String. Use it in APIs where you sometimes need to modify the string and sometimes can return it unchanged:
use std::borrow::Cow;
fn normalize(s: &str) -> Cow<'_, str> {
if s.chars().all(|c| c.is_lowercase()) {
Cow::Borrowed(s) // No allocation needed
} else {
Cow::Owned(s.to_lowercase()) // Allocate only when needed
}
}
fn main() {
let lower = normalize("already lowercase");
let mixed = normalize("Mixed Case");
println!("{}", lower); // Borrowed — no allocation
println!("{}", mixed); // Owned — allocated
}
Common Patterns
Build a String Efficiently
fn main() {
// Avoid: repeated String + &str (allocates for each concatenation)
let parts = vec!["one", "two", "three", "four"];
// Good: collect with join
let joined = parts.join(", ");
println!("{}", joined); // one, two, three, four
// Good: use String::with_capacity to pre-allocate
let mut result = String::with_capacity(100);
for (i, part) in parts.iter().enumerate() {
if i > 0 { result.push_str(", "); }
result.push_str(part);
}
println!("{}", result);
// Good: fold with format
let csv: String = parts.iter()
.enumerate()
.fold(String::new(), |mut acc, (i, s)| {
if i > 0 { acc.push(','); }
acc.push_str(s);
acc
});
println!("{}", csv);
}
String → Bytes and Back
fn main() {
let s = String::from("hello");
// String to bytes
let bytes: Vec<u8> = s.into_bytes();
println!("{:?}", bytes); // [104, 101, 108, 108, 111]
// Bytes back to String
let s2 = String::from_utf8(bytes).unwrap();
println!("{}", s2); // hello
// For potentially invalid UTF-8
let bytes = vec![104, 101, 108, 108, 111, 255]; // 255 is invalid UTF-8
match String::from_utf8(bytes) {
Ok(s) => println!("{}", s),
Err(e) => println!("Invalid UTF-8: {}", e),
}
// Lossy conversion (replaces invalid bytes with U+FFFD)
let lossy = String::from_utf8_lossy(&[104, 255, 111]);
println!("{}", lossy); // h<replacement char>o
}
Summary
- Use
&strfor function parameters that just read string data — it accepts both&Stringand string literals - Use
Stringwhen you own string data, need to build it dynamically, or return it from a function - UTF-8 means no integer indexing — use
.chars()for characters,.bytes()for raw bytes, or slice on verified char boundaries format!is the clearest way to combine strings without ownership headachesCow<str>when you want to defer allocation until you actually need to modify the string
Comments