Strings in Rust - Understanding String and &str

Master Rust's String and &str types. Learn how to create, modify, and iterate over strings while navigating UTF-8 encoding, ownership, and common pitfalls.

Rust’s approach to text is shaped by two forces that rarely coexist in other languages: absolute memory safety and full Unicode support. The result is a pair of types — String and &str — that handle everything from tiny string literals to gigabytes of user input without garbage collection or hidden allocations. This document covers how they work, how they differ, and how to use them confidently.

The Two String Types

In Rust, text data comes in two distinct forms: &str (pronounced “string slice”) and String. They are not interchangeable, and understanding their relationship is the foundation of all string handling in the language.

&str — A Reference to a Fixed Sequence of UTF-8 Bytes

A &str is a borrowed view into a sequence of valid UTF-8 bytes that already lives somewhere in memory. It does not own the data; it points to data owned by something else — a String, a static binary, or another slice. Because it never allocates or deallocates, creating and copying a &str is extremely cheap.

let greeting: &str = "Hello, world!"; // lives in the compiled binary's read-only memory

The size of a &str is two machine words: a pointer to the start of the bytes and a length. It has no capacity field because it cannot grow. This is the type you get from string literals and from borrowing a String with &my_string.

A string slice can be taken from the middle of a larger string as long as the split lands on valid UTF-8 character boundaries. If the split happens mid-character, Rust panics at runtime.

let sparkle_heart = "💖";  // 4 bytes
let s = &sparkle_heart[..2]; // panic: byte index 2 is not a char boundary

Rust refuses to create an invalid &str because the type’s very existence promises the bytes are valid UTF-8. This invariant is enforced every time a string slice is created.

String — An Owned, Growable UTF-8 Buffer

A String is an owned, heap-allocated sequence of UTF-8 bytes. It can grow and shrink, and it is responsible for freeing the memory when it goes out of scope. Internally, a String is a wrapper around Vec<u8> with the same UTF-8 guarantee: you can never have a String that contains invalid UTF-8.

let mut s = String::new();
s.push_str("hello");

A String has three components: a pointer to the heap buffer, a length (number of bytes currently used), and a capacity (total allocated bytes without reallocation). This matches the layout of Vec<u8> exactly.

&str is to String as &[T] is to Vec<T>:

If the relationship feels familiar, it is. &str is to String exactly what &[T] is to Vec<T> — an immutable borrowed slice of a growable owned sequence. The same ownership and borrowing rules apply: you can have many &str references to a String, but only one &mut String while those references exist.

Beginners often ask: “If String owns the data, why do I see &str everywhere?” The answer is ergonomics. Most functions that read text do not need to own it. Accepting &str lets them work with both string literals and parts of String values without copying. It is the cheapest way to take a text argument.

Creating Strings

Rust provides several ways to bring a String into existence, each suited to a different starting point.

From a string literal — to_string()
Any &str can be turned into an owned String by calling .to_string(). This allocates new memory and copies the bytes.

let s = "initial contents".to_string();

From a string literal — String::from()
The From trait implementation does the same allocation and copy, but reads more directly when you know the literal.

let s = String::from("initial contents");

Starting empty — String::new()
Creates a String with no allocated buffer. The first mutation will trigger the initial allocation.

let mut s = String::new();
s.push_str("build as you go");

With a known capacity — String::with_capacity(n)
When you know roughly how many bytes the final string will hold, pre-allocating prevents repeated reallocations as the string grows.

let mut s = String::with_capacity(200);
// do many push operations...

Pre-allocation reduces reallocation cost:

If you are building a large string in a loop, pre-allocating the approximate size with with_capacity avoids the cost of multiple reallocations and memory copies. The string will still grow if you exceed the initial capacity, but you pay the reallocation tax fewer times.

Every creation method produces a String that adheres to the UTF-8 guarantee. You cannot construct a String from arbitrary bytes directly — the standard library’s safe API always checks for validity.

Updating and Concatenating Strings

Growing a string is done with methods that append data to the end, respecting UTF-8. The choice of method depends on whether you are adding a single character, a whole slice, or combining multiple strings.

Appending with push and push_str

push adds a single char (a Unicode scalar value). push_str adds a &str — a whole borrowed string segment. Both operate in-place and will reallocate if the current capacity is insufficient.

let mut s = String::from("foo");
s.push_str("bar"); // s is now "foobar"
s.push('!');       // s is now "foobar!"

The data in s remains valid UTF-8 because push_str enforces that the incoming &str is valid, and push only accepts a single scalar value. There is no way to push a raw byte that would break the UTF-8 invariant through safe code.

Concatenation with the + operator

Rust’s + operator for strings is less general than one might expect. Its signature is fn add(self, s: &str) -> String. The left-hand String is consumed (ownership moves into add), and the right-hand side is a &str reference. This design avoids unnecessary allocations.

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 is moved; s2 remains usable

The compiler can coerce a &String to &str automatically via deref coercion, so &s2 works even though s2 is a String. After this operation, s3 contains the combined text, s1 is no longer valid, and s2 is untouched.

Multiple + operations create a cascade of allocations:

Chaining + like s1 + &s2 + &s3 creates an intermediate String for each + call, each requiring an allocation and copy. For combining many strings, the format! macro or push_str in a loop is both clearer and faster.

Concatenation with the format! macro

format! works like println! but returns a String instead of writing to stdout. It does not take ownership of any of its arguments — it borrows them, formats the output, and allocates a new String with the result.

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let game = format!("{}-{}-{}", s1, s2, s3);
// s1, s2, and s3 are all still usable

format! is the preferred tool when you have three or more pieces to combine and need the originals to survive. Under the hood it uses a String buffer and write operations, so it is usually efficient, but it always creates a new allocation — unlike + which reuses the left-hand buffer.

Why You Can’t Index Into a String

In many languages, my_string[0] gives you the first character. In Rust, this does not compile.

let s = String::from("hello");
// let c = s[0]; // compile error: the type `String` cannot be indexed by `{integer}`

The reason is UTF-8. Some Unicode characters take one byte, others take two, three, or four. The character 'é' (lowercase e with acute accent) is two bytes: [0xC3, 0xA9]. If s[0] returned the first byte, it would give a meaningless fragment of a multi-byte character. If it returned the first character, the operation would not be O(1) — it would require scanning from the beginning to find character boundaries. Rust refuses to silently provide either behavior.

This design forces you to be explicit about what you mean. Do you want bytes? Scalar values? Grapheme clusters? Each interpretation requires a different method, and none of them look like [0].

Accidental indexing would silently produce garbage:

If Rust allowed indexing and returned a byte, a new programmer might write s[3] on a string containing emoji and get a byte that is part of a multi-byte sequence, with no warning. The compile error prevents that entire class of bug.

To extract a sub-string by position, you use slicing with ranges — but only on byte indices that fall on character boundaries. Slicing at an invalid boundary panics.

let hello = "Здравствуйте"; // each Cyrillic character is 2 bytes
let s = &hello[0..4];        // first two characters: "Зд"
// &hello[0..1] would panic — 0..1 splits a character in half

For safe slicing, use the char_indices() method to compute valid byte positions, or rely on crates like unicode-segmentation for grapheme-aware operations.

Iterating Over Strings

Rust provides three built-in iteration methods on strings, each producing a different unit of text. Choosing the right one depends on what you need to process: raw bytes, Unicode scalar values (characters), or grapheme clusters (what a user calls a “character”).

Bytes — bytes()

Returns an iterator over each raw byte of the string’s UTF-8 encoding. This is useful when you need to interact with protocols or file formats that work at the byte level.

for b in "नमस्ते".bytes() {
    println!("{b}"); // prints a sequence of bytes like 224, 164, 168, ...
}

Scalar values — chars()

Returns an iterator over each Unicode scalar value. Every char in Rust is 4 bytes and represents a complete code point from U+0000 to U+D7FF and U+E000 to U+10FFFF. This is what most programmers think of when they say “character” — but it is not exactly what a user sees as a single letter. The Hindi word “नमस्ते” is composed of six char values: ['न', 'म', 'स', '्', 'त', 'े']. Two of those are combining marks that modify the preceding consonant.

for c in "नमस्ते".chars() {
    println!("{c}"); // prints 'न', 'म', 'स', '्', 'त', 'े'
}

Grapheme clusters — the unicode-segmentation crate

A grapheme cluster is what a human considers a single character. In “नमस्ते”, there are four clusters: “न”, “म”, “स्”, “ते”. The standard library does not provide grapheme iteration directly, but the widely used unicode-segmentation crate does.

use unicode_segmentation::UnicodeSegmentation;
for g in "नमस्ते".graphemes(true) {
    println!("{g}"); // prints "न", "म", "स्", "ते"
}

Which iterator should I use?:

If you are writing a text renderer or a CLI that displays human-readable output, use grapheme clusters. If you are parsing a programming language or a format that operates on code points, chars() is correct. If you are implementing a network protocol or hashing raw content, bytes() is what you need. The three layers exist because “text” means different things at different levels of abstraction.

When you iterate over a String directly in a for loop without calling a method, you get an iteration over &str — but only because the IntoIterator trait for &String yields &str, which does not directly iterate. In practice, you always call one of the three explicit iterators.

Common Mistakes and How to Avoid Them

Working with Rust strings means unlearning shortcuts from other languages that hide complexity. The following pitfalls account for most of the frustration newcomers experience.

Trying to index like an array
Beginners coming from Python or JavaScript instinctively write s[0]. The fix is to ask what you actually want: s.chars().nth(0) for the first scalar value, or a grapheme library for what a user sees as the first character.

Forgetting that + consumes the left-hand String
After let s3 = s1 + &s2;, s1 is gone. If you need s1 later, use format! instead, or clone s1 before the operation: let s3 = s1.clone() + &s2;.

Slicing with a byte index that is not on a char boundary
A slice like &my_string[0..3] will compile but may panic at runtime if byte 3 falls in the middle of a multi-byte character. Always validate indices when slicing user input or dynamic content.

Confusing String and &str in function signatures
Functions that only read text should take &str as a parameter. This makes them work with both string literals and owned String values without ownership transfer. Reserve String parameters for cases where the function actually needs to own the data — for example, to store it in a struct.

fn greet(name: &str) {
    println!("Hello, {name}!");
}
greet("Alice");           // works with &str
greet(&String::from("Bob")); // works with borrowed String

Repeated + concatenation in a loop
Each + (or push_str in a loop that triggers reallocation) can degrade performance if the final size is unknown and the string grows many times. Use with_capacity to pre-allocate or collect into a Vec<String> and then join. The itertools crate’s join method on iterators can also help.

Clone is cheap — but not free:

Cloning a String copies the entire heap buffer. That is fine for small strings, but cloning a multi-megabyte string in a hot loop will hurt performance. If you only need a read-only view, borrow with & instead.

Summary

Rust splits text into String and &str to give you precise control over ownership and allocation while guaranteeing UTF-8 correctness. The language blocks unsafe patterns at compile time — like indexing into a variable-width encoding — and provides explicit iterators for bytes, scalar values, and grapheme clusters so that you always know exactly what unit of text you are processing.

What separates Rust’s strings from those in most other languages is not that they are more difficult, but that they force you to answer questions most languages let you ignore. Are you handling text as raw bytes? As code points? As human-readable characters? Rust demands an answer, and the type system ensures you cannot accidentally mix them.