Strings in Rust
Strings in Rust are more nuanced than in most languages. Rather than a single string type, Rust provides two: one for owned, heap-allocated text and one for borrowed slices of existing text. Understanding the difference is essential for writing correct, efficient Rust programs.
Two String Types
Rust's standard library exposes two primary string types:
String— an owned, heap-allocated, growable sequence of UTF-8 bytes. When aStringgoes out of scope, its memory is automatically freed.&str— a borrowed string slice, a reference to a sequence of UTF-8 bytes stored somewhere (the program binary, the heap, or the stack). It does not own the data and cannot grow.
String literals like "hello" have type &str. They are embedded directly in the
compiled binary and are available for the entire lifetime of the program.
Neither type stores characters as simple bytes. Both types guarantee that their contents are valid UTF-8 at all times, which means a single character can occupy anywhere from 1 to 4 bytes.
Feature | String | &str |
|---|---|---|
Ownership | Owned | Borrowed |
Storage | Heap-allocated | Binary / heap / stack |
Mutable | Yes (with mut) | No |
Growable | Yes | No |
Typical use | Building / modifying text | Reading / passing text |
Creating a String
There are several common ways to create a String value.
Using String::from — the most explicit way:
fn main() {
let s = String::from("Hello, Rust!");
println!("{}", s);
}Hello, Rust!
Using .to_string() — works on any type that implements Display:
fn main() {
let s = "Hello, world!".to_string();
println!("{}", s);
}Hello, world!
Using String::new() followed by push_str — start empty, then append:
fn main() {
let mut s = String::new();
s.push_str("Hello");
s.push_str(", Rust!");
println!("{}", s);
}Hello, Rust!
Using format! — builds a String from a template without printing it:
fn main() {
let name = "Alice";
let score = 95;
let summary = format!("{} scored {}%", name, score);
println!("{}", summary);
}Alice scored 95%
Modifying a String
A mut String can be changed in several ways after creation.
push_str appends a &str slice. It does not take ownership of the argument,
so you can keep using the slice afterwards:
fn main() {
let mut greeting = String::from("Hello");
let suffix = ", world!";
greeting.push_str(suffix);
println!("{}", greeting);
println!("suffix still valid: {}", suffix); // suffix was borrowed, not moved
}Hello, world! suffix still valid: , world!
push appends a single char:
fn main() {
let mut s = String::from("Hel");
s.push('l');
s.push('o');
println!("{}", s);
}Hello
The + operator concatenates two strings. It takes ownership of the left-hand
String and borrows the right-hand &str:
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 is moved here; s2 is borrowed
// println!("{}", s1); // ERROR — s1 was moved
println!("{}", s3);
println!("{}", s2); // s2 is still valid
}Hello, world! world!
format! is the preferred way to combine more than two strings — it borrows
everything and returns a new String with no ownership transfers:
fn main() {
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 are all still valid here
println!("a={} b={} c={}", a, b, c);
}tic-tac-toe a=tic b=tac c=toe
Why You Cannot Index a String
In many languages you can write s[0] to get the first character of a string. In
Rust this is a compile error. The reason is UTF-8 encoding: a character may occupy
1, 2, 3, or 4 bytes. An integer index is ambiguous — does it mean the byte at that
position, or the character at that position?
Consider the string "héllo". The character é occupies 2 bytes, so:
- Byte 0 →
h(1 byte, safe) - Byte 1 → first byte of
é(not a complete character) - Byte 2 → second byte of
é(not a complete character) - Byte 3 →
l
Because the answer is always ambiguous, Rust refuses to compile s[0]:
fn main() {
let s = String::from("hello");
let c = s[0]; // ERROR: cannot index into a String with an integer
}error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:13
|
3 | let c = s[0];
| ^^^^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`Rust gives you three ways to view the bytes of a string — choose the one that matches what you actually mean:
- Bytes — the raw UTF-8 byte values (via
.bytes()) - Chars — Unicode scalar values (via
.chars()) - Grapheme clusters — what a human sees as one "character" (requires the
unicode-segmentationcrate)
String Slices
You can take a byte-range slice of a string using &s[start..end]. This returns
a &str that references the bytes from start up to (but not including) end.
fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
println!("{} {}", hello, world);
}hello world
"héllo" at [1..2] panics because byte 1 is the interior of the two-byte character é. Always slice at boundaries you have verified — use .char_indices() to find safe offsets programmatically.fn main() {
let s = "héllo";
// Safe: use char_indices to discover byte boundaries first
for (i, c) in s.char_indices() {
println!("byte {} = '{}'", i, c);
}
}byte 0 = 'h' byte 1 = 'é' byte 3 = 'l' byte 4 = 'l' byte 5 = 'o'
Iterating Over a String
The safest way to process a string character-by-character is to iterate — not index.
.chars() yields each Unicode scalar value (a char) in order:
fn main() {
let word = "Rust🦀";
for c in word.chars() {
println!("{}", c);
}
println!("Total chars: {}", word.chars().count());
}R u s t 🦀 Total chars: 5
.bytes() yields each raw byte as a u8:
fn main() {
let word = "hi";
for b in word.bytes() {
print!("{} ", b);
}
println!();
println!("Total bytes: {}", word.len());
}104 105 Total bytes: 2
.chars().count() when you need the number of visible characters.Useful String Methods
Method | Description |
|---|---|
.len() | Returns byte length (not character count) |
.is_empty() | Returns true if the string has zero bytes |
.contains(pat) | Returns true if the pattern appears anywhere in the string |
.starts_with(pat) | Returns true if the string begins with the pattern |
.ends_with(pat) | Returns true if the string ends with the pattern |
.replace(old, new) | Returns a new String with all occurrences replaced |
.trim() | Returns a &str with leading and trailing whitespace removed |
.trim_start() | Removes only leading whitespace |
.trim_end() | Removes only trailing whitespace |
.split(pat) | Returns an iterator of &str substrings split by the pattern |
.to_uppercase() | Returns a new String in uppercase |
.to_lowercase() | Returns a new String in lowercase |
.repeat(n) | Returns the string repeated n times |
fn main() {
let s = " Hello, Rust! ";
println!("len: {}", s.len());
println!("trimmed: '{}'", s.trim());
println!("contains H: {}", s.contains('H'));
println!("starts_with: {}", s.trim().starts_with("Hello"));
println!("uppercase: {}", s.trim().to_uppercase());
println!("replace: {}", s.trim().replace("Rust", "World"));
println!("repeat: {}", "ab".repeat(3));
}len: 16 trimmed: 'Hello, Rust!' contains H: true starts_with: true uppercase: HELLO, RUST! replace: Hello, World! repeat: ababab
fn main() {
let csv = "one,two,three,four";
for part in csv.split(',') {
println!("{}", part);
}
}one two three four
Converting Between Types
&String coerces to &str automatically through Rust's Deref trait. You
almost never need to convert explicitly — pass a &String where a &str is expected
and the compiler handles it:
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let owned = String::from("Alice");
greet(&owned); // &String coerces to &str automatically
greet("Bob"); // &str literal works directly
}Hello, Alice! Hello, Bob!
Converting a number to a String — use .to_string() or format!:
fn main() {
let n: i32 = 42;
let s1 = n.to_string();
let s2 = format!("value is {}", n);
println!("{}", s1);
println!("{}", s2);
}42 value is 42
Parsing a &str into a number — use .parse(), which returns a Result:
fn main() {
let text = "100";
let number: i32 = text.parse().expect("not a valid integer");
println!("parsed: {}", number + 1);
// Handle the error explicitly instead of panicking
match "abc".parse::<i32>() {
Ok(n) => println!("parsed: {}", n),
Err(e) => println!("parse error: {}", e),
}
}parsed: 101 parse error: invalid digit found in string
&str over &String in function parameters. A function that accepts &str works with both string literals and owned String values (via automatic coercion), making it more flexible and idiomatic. Reserve String parameters for when the function needs to take ownership of the text.