ZetCode

Rust strings

last modified October 25, 2026

In this article we show how to work with strings in Rust.

Rust has two main string types. A string slice, &str, is a borrowed view into string data that is stored somewhere else: in the program binary for string literals, or in a String. A slice cannot grow and does not own its data, which makes it cheap to pass around. It is the usual type of function parameters that only read text.

An owned string, String, is a growable string stored on the heap. It owns its data, so it can be modified and returned from functions. We use it when we build, change, or store text. A &String converts automatically into a &str, so functions that take &str accept both types.

Both types always contain valid UTF-8. A character can take one to four bytes, so strings cannot be indexed by an integer, and their length is measured in bytes.

The examples were tested with Rust 1.97 and the 2024 edition. They are run as Cargo projects with cargo run -q.

Creating strings

String literals have the type &'static str. A String is created from a literal, from other values, or with the format! macro.

main.rs
fn main() {
    let word1: &str = "falcon";
    let word2: String = String::from("eagle");

    let s1 = "hawk".to_string();
    let s2 = "owl".to_owned();
    let s3 = String::from(word1);
    let s4 = format!("{word1} and {word2}");
    let empty = String::new();

    println!("{word1} {word2}");
    println!("{s1} {s2} {s3}");
    println!("{s4}");
    println!("{:?} is empty: {}", empty, empty.is_empty());
}

The example creates a string slice and several owned strings.

let word1: &str = "falcon";
let word2: String = String::from("eagle");

The word1 variable is a reference to a literal stored in the program binary. The String::from function copies the text into a new heap allocation.

let s1 = "hawk".to_string();
let s2 = "owl".to_owned();
let s3 = String::from(word1);

The to_string, to_owned, and String::from calls are equivalent for string slices. The to_string method works for every type that implements Display.

let s4 = format!("{word1} and {word2}");
let empty = String::new();

The format! macro builds a String from a format string. The String::new function creates an empty string, which does not allocate.

$ cargo run -q
falcon eagle
hawk owl falcon
falcon and eagle
"" is empty: true

String parameters

A function that only reads a string should take a &str parameter. Then it accepts string literals, references to String values, and parts of strings.

main.rs
fn shout(text: &str) -> String {
    text.to_uppercase() + "!"
}

fn main() {
    let owned = String::from("an old falcon");
    let literal = "a blue sky";

    println!("{}", shout(&owned));
    println!("{}", shout(literal));
    println!("{}", shout(&owned[3..6]));

    let s: &str = &owned;
    println!("{s}");
}

The shout function returns a new string in uppercase with an exclamation mark.

fn shout(text: &str) -> String {
    text.to_uppercase() + "!"
}

The function borrows the text and returns a new owned String.

println!("{}", shout(&owned));
println!("{}", shout(literal));
println!("{}", shout(&owned[3..6]));

The &owned expression has the type &String; the compiler converts it to &str by deref coercion. A slice of a string can be passed as well.

$ cargo run -q
AN OLD FALCON!
A BLUE SKY!
OLD!
an old falcon

Length and UTF-8

The len method returns the length in bytes, not in characters.

main.rs
fn main() {
    let words = ["falcon", "čerešňa", "東京", "🦀"];

    for w in words {
        println!(
            "{w}: {} bytes, {} chars",
            w.len(),
            w.chars().count()
        );
    }

    let w = "čerešňa";
    println!("{:?}", w.as_bytes());
}

We print the number of bytes and characters of several words.

println!(
    "{w}: {} bytes, {} chars",
    w.len(),
    w.chars().count()
);

ASCII characters take one byte. The Slovak letters č, š, and ň take two bytes, Japanese characters three, and the crab emoji four. Counting characters with chars().count() has to go through the whole string.

println!("{:?}", w.as_bytes());

The as_bytes method returns the underlying bytes. The letter č is encoded as the bytes 196 and 141.

$ cargo run -q
falcon: 6 bytes, 6 chars
čerešňa: 10 bytes, 7 chars
東京: 6 bytes, 2 chars
🦀: 4 bytes, 1 chars
[196, 141, 101, 114, 101, 197, 161, 197, 136, 97]

Iterating over characters

The chars method returns an iterator over the characters of a string, char_indices adds their byte positions, and bytes iterates over the bytes.

main.rs
fn main() {
    let word = "čajka";

    for ch in word.chars() {
        print!("{ch} ");
    }
    println!();

    for (i, ch) in word.char_indices() {
        print!("{i}:{ch} ");
    }
    println!();

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

    let first = word.chars().next();
    let last = word.chars().last();
    let third = word.chars().nth(2);
    println!("{first:?} {last:?} {third:?}");
}

We go over the word "čajka" in three ways.

for (i, ch) in word.char_indices() {
    print!("{i}:{ch} ");
}

The first character takes two bytes, so the second character starts at the byte position 2.

let first = word.chars().next();
let last = word.chars().last();
let third = word.chars().nth(2);

Since a string cannot be indexed by characters, we get single characters from the iterator. The methods return an Option<char>, because the string may be too short.

$ cargo run -q
č a j k a 
0:č 2:a 3:j 4:k 5:a 
196 141 97 106 107 97 
Some('č') Some('a') Some('j')

Adding text

A mutable String can grow. The push method adds a character and push_str adds a string slice.

main.rs
fn main() {
    let mut s = String::from("Hello");

    s.push(',');
    s.push_str(" Rust");
    s += "!";
    println!("{s}");

    s.insert_str(0, "Oh, ");
    s.insert(0, '¡');
    println!("{s}");

    let mut msg = String::with_capacity(32);
    for word in ["sky", "rock", "water"] {
        msg.push_str(word);
        msg.push(' ');
    }
    println!("{msg:?} len {} capacity {}", msg.len(), msg.capacity());
}

We add characters and strings to the end and to the start of a string.

s.push(',');
s.push_str(" Rust");
s += "!";

The += operator calls push_str. Note the single quotes for the char type and the double quotes for strings.

s.insert_str(0, "Oh, ");
s.insert(0, '¡');

The insert and insert_str methods take a byte index, which must lie on a character boundary; otherwise they panic. Inserting at the start moves all the following bytes.

let mut msg = String::with_capacity(32);

If we know the approximate size, with_capacity allocates the memory in advance, so that the string does not have to reallocate while it grows.

$ cargo run -q
Hello, Rust!
¡Oh, Hello, Rust!
"sky rock water " len 15 capacity 32

Concatenation

Strings can be joined with the + operator, with the format! macro, and with the concat and join methods of slices.

main.rs
fn main() {
    let s1 = String::from("an old");
    let s2 = String::from("falcon");

    let res = s1.clone() + " " + &s2;
    println!("{res}");

    let res = format!("{s1} {s2}");
    println!("{res}");

    let parts = ["an", "old", "falcon"];
    println!("{}", parts.concat());
    println!("{}", parts.join(" "));
    println!("{}", parts.join("-"));

    let res = s1 + " " + &s2;
    println!("{res}");
}

The example joins strings in several ways.

let res = s1.clone() + " " + &s2;

The + operator takes a String on the left side and a &str on the right. It moves the left string, appends to it, and returns it. We clone s1 to be able to use it later.

let res = format!("{s1} {s2}");

The format! macro takes its arguments by reference, so both strings remain usable. It is the clearest way to build a string from several pieces.

println!("{}", parts.concat());
println!("{}", parts.join(" "));

The concat method joins the strings of a slice without a separator; join puts a separator between them.

let res = s1 + " " + &s2;

Here s1 is moved into the result and cannot be used after this line. No copy of its data is made.

$ cargo run -q
an old falcon
an old falcon
anoldfalcon
an old falcon
an-old-falcon
an old falcon

Slicing strings

A string slice is created with a range of byte indexes. The start and the end of the range must lie on character boundaries.

main.rs
fn main() {
    let phrase = String::from("an old falcon");

    let w1 = &phrase[3..6];
    let w2 = &phrase[7..];
    let w3 = &phrase[..2];

    println!("{w1} | {w2} | {w3}");

    let word = "čerešňa";
    println!("{}", &word[0..2]);
    println!("{:?}", word.get(0..1));
    println!("{:?}", word.get(0..3));
    println!("{}", word.is_char_boundary(1));

    let first3: String = word.chars().take(3).collect();
    println!("{first3}");
}

We take parts of an ASCII string and of a string with accented letters.

let w1 = &phrase[3..6];
let w2 = &phrase[7..];
let w3 = &phrase[..2];

The start index is inclusive and the end index exclusive. An omitted index means the start or the end of the string.

println!("{}", &word[0..2]);
println!("{:?}", word.get(0..1));
println!("{:?}", word.get(0..3));
println!("{}", word.is_char_boundary(1));

The first two bytes form the letter č. The get method returns None instead of panicking when the range does not lie on character boundaries.

let first3: String = word.chars().take(3).collect();

To take a number of characters instead of bytes, we use the chars iterator.

$ cargo run -q
old | falcon | an
č
None
Some("če")
false
čer

Slicing inside a character

When a range ends inside a multi-byte character, the program panics.

main.rs
fn main() {
    let word = "čerešňa";
    let s = &word[0..1];
    println!("{s}");
}

The letter č occupies the bytes 0 and 1. The range 0..1 ends in the middle of it.

let s = &word[0..1];

The panic message names the character and its byte range. Slicing with indexes is safe for strings that we know to be ASCII, or with positions returned by methods like find or char_indices.

$ cargo run -q

thread 'main' (66551) panicked at src/main.rs:3:18:
end byte index 1 is not a char boundary; it is inside 'č' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Splitting strings

Rust has many methods to split strings. They return iterators of string slices that borrow from the original string.

main.rs
fn main() {
    let text = "Rust is  fast and\tsafe";
    let words: Vec<&str> = text.split_whitespace().collect();
    println!("{words:?}");

    let csv = "John,Doe,gardener,,1200";
    let fields: Vec<&str> = csv.split(',').collect();
    println!("{fields:?}");

    let parts: Vec<&str> = csv.splitn(3, ',').collect();
    println!("{parts:?}");

    if let Some((key, value)) = "port=8080".split_once('=') {
        println!("key: {key}, value: {value}");
    }

    let path = "/home/user/docs/notes.txt";
    println!("{:?}", path.rsplit_once('/'));

    let poem = "first line\nsecond line\r\nthird line";
    for line in poem.lines() {
        println!("{line:?}");
    }

    let s = "a1b22c333";
    let nums: Vec<&str> = s.split(|c: char| c.is_alphabetic()).collect();
    println!("{nums:?}");
}

The example splits strings by whitespace, by a character, and by a closure.

let words: Vec<&str> = text.split_whitespace().collect();

The split_whitespace method splits at any amount of whitespace, including tabs, and skips empty parts.

let fields: Vec<&str> = csv.split(',').collect();
let parts: Vec<&str> = csv.splitn(3, ',').collect();

The split method keeps empty parts between two separators. The splitn method returns at most the given number of parts.

if let Some((key, value)) = "port=8080".split_once('=') {

The split_once method splits at the first occurrence of the separator and returns the two parts as a tuple; rsplit_once splits at the last one.

for line in poem.lines() {

The lines method splits at \n and \r\n line endings.

let nums: Vec<&str> = s.split(|c: char| c.is_alphabetic()).collect();

The separator can also be a closure. The string starts with a separator, so the first part is empty.

$ cargo run -q
["Rust", "is", "fast", "and", "safe"]
["John", "Doe", "gardener", "", "1200"]
["John", "Doe", "gardener,,1200"]
key: port, value: 8080
Some(("/home/user/docs", "notes.txt"))
"first line"
"second line"
"third line"
["", "1", "22", "333"]

Trimming

The trim methods remove whitespace or other characters from the ends of a string. They return slices of the original string.

main.rs
fn main() {
    let text = "\t\tan old falcon   \n";

    println!("{:?} {}", text, text.len());
    println!("{:?}", text.trim());
    println!("{:?}", text.trim_start());
    println!("{:?}", text.trim_end());

    let s = "xxhelloxx";
    println!("{}", s.trim_matches('x'));
    println!("{}", "--==title==--".trim_matches(|c| c == '-' || c == '='));

    let file = "report.txt";
    println!("{:?}", file.strip_suffix(".txt"));
    println!("{:?}", file.strip_suffix(".csv"));
    println!("{:?}", "https://example.com".strip_prefix("https://"));
}

We remove whitespace, custom characters, a suffix, and a prefix.

println!("{:?}", text.trim());
println!("{:?}", text.trim_start());
println!("{:?}", text.trim_end());

The trim method removes whitespace from both ends; trim_start and trim_end from one end. The debug format shows the remaining tabs and newlines.

println!("{}", "--==title==--".trim_matches(|c| c == '-' || c == '='));

The trim_matches method removes all occurrences of a pattern from both ends. The pattern can be a character, a string, a slice of characters, or a closure.

println!("{:?}", file.strip_suffix(".txt"));

The strip_prefix and strip_suffix methods remove the pattern exactly once and return None if the string does not start or end with it.

$ cargo run -q
"\t\tan old falcon   \n" 19
"an old falcon"
"an old falcon   \n"
"\t\tan old falcon"
hello
title
Some("report")
None
Some("example.com")

Searching

The contains, starts_with, and ends_with methods check for a pattern; find and rfind return its byte position.

main.rs
fn main() {
    let text = "Rust programming is fun. Rust is fast.";

    println!("{}", text.contains("Rust"));
    println!("{}", text.starts_with("Rust"));
    println!("{}", text.ends_with('.'));

    println!("{:?}", text.find("Rust"));
    println!("{:?}", text.rfind("Rust"));
    println!("{:?}", text.find("Go"));
    println!("{:?}", text.find(char::is_whitespace));

    println!("{}", text.matches("is").count());

    let idx: Vec<usize> = text.match_indices("Rust").map(|(i, _)| i).collect();
    println!("{idx:?}");
}

We search for substrings and characters in a text.

println!("{:?}", text.find("Rust"));
println!("{:?}", text.rfind("Rust"));
println!("{:?}", text.find("Go"));

The find method returns the position of the first occurrence, rfind of the last one; None means not found.

println!("{:?}", text.find(char::is_whitespace));

A function that takes a char and returns bool can be used as a pattern.

println!("{}", text.matches("is").count());
let idx: Vec<usize> = text.match_indices("Rust").map(|(i, _)| i).collect();

The matches method returns an iterator over the occurrences; match_indices also returns their positions.

$ cargo run -q
true
true
true
Some(0)
Some(25)
None
Some(4)
2
[0, 25]

Replacing

The replace and replacen methods return a new string; replace_range changes a String in place.

main.rs
fn main() {
    let text = "I like cats. My cats are lazy cats.";

    println!("{}", text.replace("cats", "dogs"));
    println!("{}", text.replacen("cats", "dogs", 1));
    println!("{}", text.replace(['.', ' '], "_"));

    let mut s = String::from("an old falcon");
    s.replace_range(7.., "eagle");
    println!("{s}");
}

We replace words and characters.

println!("{}", text.replace("cats", "dogs"));
println!("{}", text.replacen("cats", "dogs", 1));

The replace method replaces all occurrences; replacen replaces at most the given number of them.

println!("{}", text.replace(['.', ' '], "_"));

An array of characters is a pattern that matches any of them.

s.replace_range(7.., "eagle");

The replace_range method replaces the bytes in the given range; the new text can have a different length.

$ cargo run -q
I like dogs. My dogs are lazy dogs.
I like dogs. My cats are lazy cats.
I_like_cats__My_cats_are_lazy_cats_
an old eagle

Changing case

The to_uppercase and to_lowercase methods follow the Unicode rules; the ascii variants change only ASCII letters.

main.rs
fn main() {
    let word = "Straße";

    println!("{}", word.to_uppercase());
    println!("{}", word.to_lowercase());
    println!("{}", word.to_ascii_uppercase());

    println!("{}", "RUST" == "rust");
    println!("{}", "RUST".eq_ignore_ascii_case("rust"));
    println!("{}", "ÉTÉ".to_lowercase() == "été".to_lowercase());

    let title = capitalize("falcon");
    println!("{title}");
}

fn capitalize(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        Some(first) => first.to_uppercase().chain(chars).collect(),
        None => String::new(),
    }
}

We change the case of a German word and compare strings without regard to case.

println!("{}", word.to_uppercase());
println!("{}", word.to_ascii_uppercase());

The uppercase form of ß is SS, so the result is longer than the original. The ASCII variant leaves the letter unchanged.

println!("{}", "RUST".eq_ignore_ascii_case("rust"));

The comparison with == is case-sensitive. The eq_ignore_ascii_case method compares without allocating; for non-ASCII text, we compare the lowercase forms.

fn capitalize(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        Some(first) => first.to_uppercase().chain(chars).collect(),
        None => String::new(),
    }
}

The function changes the first character to uppercase. The to_uppercase method of char returns an iterator, because some characters become several characters. We chain it with the rest of the characters and collect the result.

$ cargo run -q
STRASSE
straße
STRAßE
false
true
true
Falcon

Removing text

A String has several methods that remove characters.

main.rs
fn main() {
    let mut s = String::from("Hello, world!");

    let last = s.pop();
    println!("{last:?} {s}");

    let first = s.remove(0);
    println!("{first} {s}");

    s.truncate(4);
    println!("{s}");

    let mut t = String::from("h-e-l-l-o w-o-r-l-d");
    t.retain(|c| c != '-');
    println!("{t}");

    let removed: String = t.drain(..6).collect();
    println!("{removed:?} {t:?}");

    t.clear();
    println!("{t:?} {}", t.is_empty());
}

We remove characters from the end, from the start, and from the middle of a string.

let last = s.pop();
let first = s.remove(0);

The pop method removes the last character and returns an Option<char>. The remove method removes the character at the given byte index.

s.truncate(4);

The truncate method shortens the string to the given number of bytes.

t.retain(|c| c != '-');

The retain method keeps only the characters for which the closure returns true.

let removed: String = t.drain(..6).collect();

The drain method removes a range and returns the removed characters as an iterator.

$ cargo run -q
Some('!') Hello, world
H ello, world
ello
hello world
"hello " "world"
"" true

Reversing strings

A string is reversed by reversing the iterator of its characters.

main.rs
fn main() {
    let word = "falcon";
    let rev: String = word.chars().rev().collect();
    println!("{rev}");

    let words = ["level", "Kayak", "rust", "Was it a car or a cat I saw"];
    for w in words {
        println!("{w:?} palindrome: {}", is_palindrome(w));
    }
}

fn is_palindrome(s: &str) -> bool {
    let chars: Vec<char> = s
        .chars()
        .filter(|c| c.is_alphanumeric())
        .flat_map(char::to_lowercase)
        .collect();

    chars.iter().eq(chars.iter().rev())
}

We reverse a word and check palindromes.

let rev: String = word.chars().rev().collect();

The characters are taken from the end and collected into a new string. Reversing the bytes would break multi-byte characters.

let chars: Vec<char> = s
    .chars()
    .filter(|c| c.is_alphanumeric())
    .flat_map(char::to_lowercase)
    .collect();

chars.iter().eq(chars.iter().rev())

For the palindrome check, we keep only letters and digits in lowercase. The eq method compares two iterators element by element.

$ cargo run -q
noclaf
"level" palindrome: true
"Kayak" palindrome: true
"rust" palindrome: false
"Was it a car or a cat I saw" palindrome: true

Comparing and sorting

Strings are compared lexicographically by their bytes, which for UTF-8 is the same as comparing by the Unicode code points.

main.rs
fn main() {
    let a = String::from("apple");
    let b = "apple";

    println!("{}", a == b);
    println!("{}", "apple" < "banana");
    println!("{}", "Zebra" < "apple");
    println!("{:?}", "apple".cmp("apricot"));

    let mut words = vec!["banana", "Cherry", "apple", "date"];
    words.sort();
    println!("{words:?}");

    words.sort_by_key(|w| w.to_lowercase());
    println!("{words:?}");

    words.sort_by_key(|w| w.len());
    println!("{words:?}");
}

We compare strings and sort a vector of words.

println!("{}", a == b);

A String can be compared with a &str directly.

println!("{}", "Zebra" < "apple");

Uppercase ASCII letters have smaller codes than lowercase letters, so "Zebra" is less than "apple". The default sort puts "Cherry" first.

words.sort_by_key(|w| w.to_lowercase());

For a case-insensitive order, we sort by the lowercase form. Sorting according to the rules of a language requires a collation library.

words.sort_by_key(|w| w.len());

The sort is stable: words of the same length keep their previous order.

$ cargo run -q
true
true
true
Less
["Cherry", "apple", "banana", "date"]
["apple", "banana", "Cherry", "date"]
["date", "apple", "banana", "Cherry"]

Parsing and converting

The parse method converts a string into a value of any type that implements the FromStr trait. The to_string method converts values into strings.

main.rs
fn main() {
    let n: i32 = "42".parse().unwrap();
    let x = "3.14".parse::<f64>().unwrap();
    let b: bool = "true".parse().unwrap();
    println!("{n} {x} {b}");

    println!("{:?}", "12a".parse::<i32>());
    println!("{:?}", " 7 ".trim().parse::<u8>());
    println!("{:?}", "300".parse::<u8>());

    let s = 255.to_string();
    let t = format!("{:08.3}", 1.23456);
    println!("{s} {t}");

    let sum: i32 = "1 2 3 4 5".split(' ').map(|p| p.parse::<i32>().unwrap()).sum();
    println!("{sum}");
}

We convert strings into numbers and booleans and back.

let n: i32 = "42".parse().unwrap();
let x = "3.14".parse::<f64>().unwrap();

The target type is given by a type annotation or with the turbofish syntax. The method returns a Result.

println!("{:?}", "12a".parse::<i32>());
println!("{:?}", " 7 ".trim().parse::<u8>());
println!("{:?}", "300".parse::<u8>());

Parsing fails for invalid digits and for values that do not fit into the type. Whitespace is not allowed, so we trim the input first.

let t = format!("{:08.3}", 1.23456);

The format! macro gives control over the result: here the number is rounded to three decimal places and padded with zeros to eight characters.

$ cargo run -q
42 3.14 true
Err(ParseIntError { kind: InvalidDigit })
Ok(7)
Err(ParseIntError { kind: PosOverflow })
255 0001.235
15

Repeating and padding

The repeat method creates a string by repeating another string. Format specifiers pad strings to a given width.

main.rs
fn main() {
    println!("{}", "ab".repeat(3));
    println!("{}", "-".repeat(20));

    let name = "Rust";
    println!("[{name:>10}]");
    println!("[{name:<10}]");
    println!("[{name:^10}]");
    println!("[{name:*^10}]");

    let width = 12;
    println!("[{name:>width$}]");
}

We repeat strings and align a word in a field of ten characters.

println!("[{name:>10}]");
println!("[{name:<10}]");
println!("[{name:^10}]");
println!("[{name:*^10}]");

The >, <, and ^ characters align the text to the right, to the left, and to the center. A character before the alignment is used as the fill character.

println!("[{name:>width$}]");

The width can be taken from a variable with the $ suffix.

$ cargo run -q
ababab
--------------------
[      Rust]
[Rust      ]
[   Rust   ]
[***Rust***]
[        Rust]

Source

The Rust Programming Language: Storing UTF-8 Encoded Text with Strings

The String type

The str type

In this article we have worked with strings in Rust. See also the Rust int to string and regular expressions in Rust articles.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.