Strings

Every Rust program deals with text, but Rust handles strings differently from most languages you may already know. Instead of one all-purpose string type, Rust gives you two: String, an owned and growable buffer, and &str, a borrowed view into string data. Understanding why Rust splits strings this way — and how it stores text as UTF-8 bytes rather than a simple array of characters — is essential to writing correct, efficient Rust code.

Overview: How Strings Work in Rust

In C, a string is a pointer to bytes ending in a null terminator. In Python or JavaScript, a string is a single opaque type managed by a garbage collector. Rust takes a third path that follows directly from its ownership model.

String is a struct that owns a heap-allocated buffer of bytes, along with a length and a capacity, much like Vec<u8> (in fact, String is built on top of Vec<u8> internally). Because String owns its data, it follows the same ownership rules as any other owned type: it has exactly one owner, and when that owner goes out of scope, Rust automatically frees the buffer. Assigning a String to another variable, or passing it by value into a function, moves ownership — the original binding becomes invalid, because String does not implement the Copy trait (copying it would mean either a deep-copy of the heap buffer on every assignment, which Rust avoids by default, or two owners pointing at the same buffer, which would cause a double free when both go out of scope).

&str, pronounced “string slice,” is a reference: a pointer plus a length pointing at UTF-8 bytes owned by someone else — often a String, or a literal baked directly into the compiled binary (a &'static str). A &str never owns the data it points to, so borrowing rules apply to it exactly as they do to any other reference: you can have many immutable &str borrows at once, but not while a mutable borrow of the same data is alive.

The other detail that trips up newcomers is encoding. Rust strings are guaranteed valid UTF-8, not a fixed-width array of characters. Many characters — including most non-English letters and emoji — take more than one byte. This is why Rust does not let you index a string with s[0] to get “the first character”: a byte index might land in the middle of a multi-byte character, which would produce garbage or a panic. Instead, Rust makes you choose explicitly: iterate over .chars() (Unicode scalar values), iterate over .bytes() (raw UTF-8 bytes), or slice by byte range with &s[start..end], which panics if the boundaries don’t fall on valid character edges. This is a deliberate trade-off: Rust refuses to hide the cost or ambiguity of text processing behind a convenient but misleading API.

Syntax

Strings can be created and grown in several ways:

String::new()
String::from("literal")
"literal".to_string()
"literal".to_owned()

let mut s = String::from("text");
s.push_str("more text");
s.push('!');

&s[start..end]   // a string slice (&str) over a byte range
Form Meaning
String::new() Creates an empty, owned, growable string
String::from("...") Creates an owned String from a string literal
"...".to_string() / .to_owned() Converts a &str into an owned String
s.push_str(&str) Appends a string slice onto the end of a String, growing it if needed
s.push(char) Appends a single character
&s[a..b] Borrows a byte range of s as a &str (must land on character boundaries)

A function that only needs to read text should take a &str parameter — this accepts both string literals and, thanks to deref coercion, references to String values. A function that needs to own or mutate the text should take a String by value instead.

Examples

Example 1: Building and concatenating strings

fn main() {
    let mut greeting = String::from("Hello");
    greeting.push_str(", ");
    greeting.push_str("world");
    greeting.push('!');

    let name = "Rust".to_string();
    let combined = greeting + " I am learning " + &name;

    println!("{}", combined);
}

Output:

Hello, world! I am learning Rust

push_str and push mutate greeting in place, growing its heap buffer as needed. The + operator is overloaded for String: greeting + " I am learning " consumes greeting by value and returns a brand-new String. That result is then combined with &name: even though name is a String, taking a reference to it (&name, a &String) is automatically converted to &str through deref coercion, because the + operator on String is defined as String + &str. Notice that after this line, greeting can no longer be used — it was moved into the addition.

Example 2: UTF-8, characters, and bytes

fn main() {
    let text = String::from("café");

    println!("Length in bytes: {}", text.len());
    println!("Number of chars: {}", text.chars().count());

    for c in text.chars() {
        print!("{} ", c);
    }
    println!();

    for b in text.bytes() {
        print!("{} ", b);
    }
    println!();
}

Output:

Length in bytes: 5
Number of chars: 4
c a f é 
99 97 102 195 169 

The word “café” has four visible characters, but é is encoded as two UTF-8 bytes (195 and 169), so len() — which reports byte length, not character count — returns 5. This is exactly why Rust refuses to let you write text[3] expecting a character: byte index 3 sits in the middle of é‘s two-byte encoding. Calling .chars() correctly decodes the bytes into four Unicode scalar values, while .bytes() exposes the raw underlying byte sequence.

Example 3: Finding, slicing, and splitting

fn main() {
    let sentence = String::from("Rust is fast and safe");

    if let Some(index) = sentence.find("fast") {
        let before = &sentence[..index];
        let after = &sentence[index..];
        println!("Before: '{}'", before);
        println!("After: '{}'", after);
    }

    let upper = sentence.to_uppercase();
    println!("Uppercase: {}", upper);

    let words: Vec<&str> = sentence.split_whitespace().collect();
    println!("Word count: {}", words.len());
    println!("Third word: {}", words[2]);
}

Output:

Before: 'Rust is '
After: 'fast and safe'
Uppercase: RUST IS FAST AND SAFE
Word count: 5
Third word: fast

find returns an Option<usize> holding the byte index where the substring starts, or None if it isn’t present — handling it with if let Some(index) avoids a panic on the “not found” case. Once we have the index, &sentence[..index] and &sentence[index..] borrow slices on either side of it. to_uppercase() allocates and returns a new String rather than modifying in place, since the uppercase version can have a different byte length for some Unicode text. split_whitespace() returns an iterator of &str slices, which we collect into a Vec<&str>.

How It Works Step by Step

Tracing example 1 shows ownership moving through the program:

  • String::from("Hello") allocates a small heap buffer and returns a String that owns it; greeting becomes that owner.
  • push_str and push borrow greeting mutably (&mut self) just long enough to append bytes, growing and reallocating the heap buffer if the current capacity is exceeded — the same amortized-growth strategy Vec<T> uses.
  • greeting + " I am learning " calls Add::add(greeting, " I am learning "), which takes ownership of greeting by value. The original greeting binding is now moved and can never be used again — the compiler tracks this statically and would reject any later use of greeting.
  • The resulting temporary String is then combined with &name (coerced to &str), producing the final combined string, which becomes the sole owner of that buffer.
  • When main ends, combined and name go out of scope in reverse order of declaration, and Rust automatically frees their heap buffers — no garbage collector, no manual free, and (thanks to the move rules) no risk of freeing the same memory twice.

The borrow checker’s job throughout is to guarantee that at every point in the program, a piece of string data has exactly one owner (or, temporarily, either one mutable reference or several immutable references to it) — never an owner plus a stale, dangling reference left over from before a move.

Common Mistakes

1. Indexing a string directly

Because byte indices don’t map cleanly onto characters, Rust doesn’t implement Index<usize> for String at all:

fn main() {
    let s = String::from("hello");
    let first = s[0]; // error: the type `String` cannot be indexed by `{integer}`
    println!("{}", first);
}

This fails to compile outright. Ask instead for what you actually mean — the first character:

fn main() {
    let s = String::from("hello");
    let first = s.chars().next().unwrap();
    println!("{}", first);
}

Output:

h

.chars().next() returns an Option<char>; .unwrap() is acceptable here only because we can see the string is non-empty at compile time — in real code operating on unknown input, match on the Option instead.

2. Using a value after it has moved

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}", s1); // error: value borrowed here after move
}

let s2 = s1; moves ownership from s1 to s2 because String isn’t Copy; s1 is invalidated immediately, so the compiler rejects the later use. If you genuinely need two independent copies, clone explicitly — it’s a visible, intentional heap allocation rather than a silent one:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("{} {}", s1, s2);
}

Output:

hello hello

3. Mutating while a borrow is alive

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;
    s.push_str(" world"); // error: cannot borrow `s` as mutable because it is also borrowed as immutable
    println!("{}", r1);
}

push_str needs a mutable borrow, but r1 is an immutable borrow that is still alive (it gets used on the next line), so the two borrows would overlap. The fix is to make sure the immutable borrow’s scope ends before the mutation happens:

fn main() {
    let mut s = String::from("hello");
    {
        let r1 = &s;
        println!("{}", r1);
    }
    s.push_str(" world");
    println!("{}", s);
}

Output:

hello
hello world

A related runtime footgun (not a compile error) is slicing on the wrong byte offset, such as &s[0..1] on a string that starts with a multi-byte character like é — this compiles fine but panics at runtime with “byte index is not a char boundary,” since the slice would cut a character in half. When slicing by hand, use .chars(), .char_indices(), or .is_char_boundary() to compute safe offsets instead of guessing.

Best Practices

  • Take &str parameters for functions that only read text; take String only when the function needs to own or mutate it.
  • Use String::from("...") or "...".to_string() to create owned strings, and prefer building them with push_str/push or format! over repeated + concatenation in loops.
  • Never assume one character equals one byte — always reach for .chars(), .bytes(), or .char_indices() instead of manual byte-index arithmetic on text that might contain non-ASCII characters.
  • Use .clone() deliberately and sparingly — it’s a real heap allocation, not a free copy, and often a borrow (&str) is all you actually need.
  • Prefer match or if let over .unwrap() when handling the Option/Result returned by methods like find, parse, or strip_prefix, since real input isn’t guaranteed to match your assumptions.
  • Reach for format!("{}...", ...) when building a string from several pieces — it’s often clearer than chained push_str calls or + operators.

Practice Exercises

  • Write a function fn word_count(text: &str) -> usize that returns the number of whitespace-separated words in a string slice, then call it on a few sentences of your own.
  • Write a function fn reverse_words(text: &str) -> String that returns a new String with the words in reverse order (for input "Rust is fun", the expected output is "fun is Rust").
  • Given a String containing a mix of ASCII and multi-byte characters (try "naïve café"), print both its byte length (.len()) and its character count (.chars().count()), and explain in a comment why the two numbers differ.

Summary

  • String is an owned, growable, heap-allocated buffer of UTF-8 bytes; &str is a borrowed view (a slice) into UTF-8 bytes owned by something else.
  • Assigning or passing a String by value moves ownership, since String does not implement Copy; use .clone() when you truly need an independent copy.
  • Rust strings are valid UTF-8, not fixed-width character arrays, so len() returns byte length, direct integer indexing (s[0]) isn’t allowed, and slicing must land on character boundaries or it panics.
  • Use .chars() to iterate Unicode scalar values, .bytes() for raw bytes, and .char_indices() when you need both a character and its byte offset.
  • Take &str for read-only function parameters and String when ownership or mutation is required — this is the idiomatic default throughout the standard library.
  • The borrow checker enforces at compile time that you never mutate a string while another part of the program still holds an immutable reference to it.