RustStrings & &str

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 a String goes 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:

RUST
fn main() {
    let s = String::from("Hello, Rust!");
    println!("{}", s);
}
Hello, Rust!

Using .to_string() — works on any type that implements Display:

RUST
fn main() {
    let s = "Hello, world!".to_string();
    println!("{}", s);
}
Hello, world!

Using String::new() followed by push_str — start empty, then append:

RUST
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:

RUST
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:

RUST
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:

RUST
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:

RUST
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:

RUST
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]:

RUST
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-segmentation crate)
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.

RUST
fn main() {
    let s = String::from("hello world");
    let hello = &s[0..5];
    let world = &s[6..11];
    println!("{} {}", hello, world);
}
hello world
Warning
Slicing at a byte offset that falls in the middle of a multi-byte character causes a runtime panic, not a compile error. For example, slicing "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.

RUST
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:

RUST
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:

RUST
fn main() {
    let word = "hi";
    for b in word.bytes() {
        print!("{} ", b);
    }
    println!();
    println!("Total bytes: {}", word.len());
}
104 105
Total bytes: 2
Note
For ASCII-only text, byte count and character count are equal. As soon as you introduce any character outside the basic ASCII range (code point 127 or higher), they will diverge. Always use .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

RUST
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

RUST
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:

RUST
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!:

RUST
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:

RUST
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
Tip
Prefer &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.