Skip to content
Learn Rust
Theme

Strings

Data · Lesson 25

Rust has two string types. String owns its text and can grow. &str is a borrowed, read-only view into text that lives somewhere else.

Coming from Go&str looks like your string: read-only UTF-8 bytes, and len() counts bytes. The difference is lifetime. A Go substring shares its parent's bytes and the GC keeps the parent alive; a &str borrows from a String and can't outlive it, which the compiler checks. Slicing that cuts a rune gives Go invalid UTF-8 silently, while Rust panics. range gives (byte index, rune), which is char_indices(), not chars().

Coming from KotlinRust's String owns its text like a Kotlin String, but it can change, like a StringBuilder. &str has no Kotlin twin: it is a borrowed view into someone else's text, with no copy, and it can't outlive the string it points into. Rust stores UTF-8, not UTF-16, so .len() counts bytes and .chars().count() counts characters.

Coming from PythonYour str splits into two types: String owns text and can grow, &str is a borrowed view. Both are UTF-8 bytes, so .len() counts bytes, and slicing like &s[0..1] uses byte positions and panics if it cuts a character in half.

Coming from TypeScriptJS strings are UTF-16, but Rust strings are UTF-8, so .len() counts bytes and .chars() walks characters like for...of. A String can also be changed in place with push_str when it's mut, and &str is a borrowed view of one.

Coming from JavaRust's String owns its text like a Java String, but can grow like a StringBuilder. &str is a borrowed, read-only view into text with no copy, and it can't outlive the string it points into. Rust text is UTF-8, so len() counts bytes, and s[1] won't compile; use chars() to walk characters.

Coming from C#String is an owned, growable string (think StringBuilder), and &str is close to ReadOnlySpan<char>: a borrowed view that can't outlive its owner. Rust stores UTF-8, not UTF-16, so len() counts bytes, and indexing with s[1] is not allowed.

Coming from CString is a heap buffer that frees itself, and &str is a pointer plus a length, like your %.*s view. Rust strings are always valid UTF-8, carry their length instead of a '\0', and can't be indexed by byte with s[1].

Coming from C++String is like std::string and &str like std::string_view. But std::string holds any bytes, while Rust guarantees valid UTF-8. Rust also refuses s[1], and the borrow checker stops a &str from outliving the String it views.

The quick comparison

Rustsrc/main.rsRun ↗
fn main() {
let owned: String = String::from("héllo"); // owns the text, can grow
let view: &str = &owned[0..1]; // a view into it, no copy
let mut s = String::from("go");
s.push_str(" + rust");
println!("{owned} {view} {s}");
println!("bytes={} chars={}", owned.len(), owned.chars().count());
for c in owned.chars() {
print!("[{c}]");
}
println!();
}
$ cargo run
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
func main() {
owned := "héllo"
view := owned[0:1]
var b strings.Builder
b.WriteString("go")
b.WriteString(" + rust")
s := b.String()
fmt.Println(owned, view, s)
fmt.Printf("bytes=%d chars=%d\n", len(owned), utf8.RuneCountInString(owned))
for _, c := range owned {
fmt.Printf("[%c]", c)
}
fmt.Println()
}
$ go run main.go
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
fun main() {
val owned = "héllo"
val view = owned.substring(0, 1)
val s = StringBuilder("go")
s.append(" + rust")
println("$owned $view $s")
println("bytes=${owned.toByteArray().size} chars=${owned.length}")
for (c in owned) {
print("[$c]")
}
println()
}
$ kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
owned = "héllo" # str is a sequence of characters, and immutable
view = owned[0:1] # slicing makes a new string
s = "go"
s += " + rust" # builds a new string
print(f"{owned} {view} {s}")
print(f"bytes={len(owned.encode())} chars={len(owned)}")
for c in owned:
print(f"[{c}]", end="")
print()
$ python3 main.py
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
const owned = "héllo"; // strings can't be changed in place
const view = owned.slice(0, 1); // slice makes a new string
let s = "go";
s += " + rust"; // makes a new string and reassigns s
console.log(`${owned} ${view} ${s}`);
const bytes = new TextEncoder().encode(owned).length; // UTF-8 bytes
const chars = [...owned].length; // real characters
console.log(`bytes=${bytes} chars=${chars}`);
let line = "";
for (const c of owned) { // for...of walks characters, not UTF-16 units
line += `[${c}]`;
}
console.log(line);
$ tsc --strict --module nodenext --types node main.ts && node main.js
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
import java.nio.charset.StandardCharsets;
public class Main {
public static void main(String[] args) {
String owned = "héllo"; // Strings never change
String view = owned.substring(0, 1); // a new String
var s = new StringBuilder("go"); // for text that grows
s.append(" + rust");
System.out.println(owned + " " + view + " " + s);
int bytes = owned.getBytes(StandardCharsets.UTF_8).length;
System.out.println("bytes=" + bytes + " chars=" + owned.length());
for (char c : owned.toCharArray()) {
System.out.print("[" + c + "]");
}
System.out.println();
}
}
$ javac Main.java && java Main
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
using System.Text;
var owned = "héllo"; // strings never change
ReadOnlySpan<char> view = owned.AsSpan(0, 1); // a view into it, no copy
var s = new StringBuilder("go"); // the growable kind
s.Append(" + rust");
Console.WriteLine($"{owned} {view} {s}");
Console.WriteLine($"bytes={Encoding.UTF8.GetByteCount(owned)} chars={owned.Length}");
foreach (var c in owned)
{
Console.Write($"[{c}]");
}
Console.WriteLine();
$ dotnet run
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
const char *owned = "héllo"; // bytes ending in '\0' (UTF-8)
// a "view" is a pointer plus a length; %.*s prints that many bytes
const char *view = owned;
int view_len = 1;
// a growable string: allocate room, then append
char *s = malloc(32);
strcpy(s, "go");
strcat(s, " + rust");
printf("%s %.*s %s\n", owned, view_len, view, s);
// count characters: skip UTF-8 continuation bytes (10xxxxxx)
size_t chars = 0;
for (const char *p = owned; *p; p++) {
if (((unsigned char)*p & 0xC0) != 0x80) chars++;
}
printf("bytes=%zu chars=%zu\n", strlen(owned), chars);
// print each character: a lead byte plus its continuation bytes
for (const char *p = owned; *p;) {
const char *start = p++;
while (((unsigned char)*p & 0xC0) == 0x80) p++;
printf("[%.*s]", (int)(p - start), start);
}
printf("\n");
free(s);
return 0;
}
$ gcc -Wall -Wextra main.c -o main && ./main
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string owned = "héllo"; // owns the text (UTF-8 bytes), can grow
std::string_view view = std::string_view(owned).substr(0, 1); // a view into it, no copy
std::string s = "go";
s += " + rust";
std::cout << owned << " " << view << " " << s << "\n";
// std::string counts bytes. Counting characters means skipping UTF-8 continuation bytes.
std::size_t chars = 0;
for (unsigned char b : owned) {
if ((b & 0xC0) != 0x80) chars++;
}
std::cout << "bytes=" << owned.size() << " chars=" << chars << "\n";
for (std::size_t i = 0; i < owned.size();) {
std::size_t len = 1;
while (i + len < owned.size() && (static_cast<unsigned char>(owned[i + len]) & 0xC0) == 0x80) len++;
std::cout << "[" << owned.substr(i, len) << "]";
i += len;
}
std::cout << "\n";
}
$ g++ -std=c++20 -Wall -Wextra main.cpp -o main && ./main
héllo h go + rust
bytes=6 chars=5
[h][é][l][l][o]

What's different

  • Rule of thumb: a function that only reads text takes &str. A struct field that keeps text uses String.
  • A &String turns into a &str automatically when you pass it, so fn f(s: &str) accepts both.
  • .len() counts bytes, not characters. é takes 2 bytes, so "héllo" is 6 bytes and 5 characters.
  • Text written in quotes, like "hi", is already a &str. Use String::from("hi") or "hi".to_string() when you need an owned String.
Gotcha

Indexing a string like an array

Looks fine: in Go, Kotlin, Python, TypeScript, Java and C# it runs fine; in C and C++ it runs but gives a wrong answer. Rust won't compile it.

Works in Go. In Rust it's just a rule.Works in Kotlin. In Rust it's just a rule.Works in Python. In Rust it's just a rule.Works in TypeScript. In Rust it's just a rule.Works in Java. In Rust it's just a rule.Works in C#. In Rust it's just a rule.Broken in C. Rust catches it.Broken in C++. Rust catches it.
Rustsrc/main.rs✗ won't compile
fn main() {
let s = String::from("héllo");
let second = s[1];
println!("{second}");
}
package main
import "fmt"
func main() {
s := "héllo"
second := s[1]
fmt.Println(second)
}
$ go run main.go✓ runs
195
fun main() {
val s = "héllo"
val second = s[1]
println(second)
}
$ kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar✓ runs
é
s = "héllo"
second = s[1]
print(second)
$ python3 main.py✓ runs
é
const s = "héllo";
const second = s[1];
console.log(second);
$ tsc --strict --module nodenext --types node main.ts && node main.js✓ runs
é
public class Main {
public static void main(String[] args) {
String s = "héllo";
char second = s.charAt(1);
System.out.println(second);
}
}
$ javac Main.java && java Main✓ runs
é
var s = "héllo";
var second = s[1];
Console.WriteLine(second);
$ dotnet run✓ runs
é
#include <stdio.h>
int main(void) {
const char *s = "héllo";
char second = s[1];
printf("%d\n", second);
return 0;
}
$ gcc -Wall -Wextra main.c -o main && ./main! runs · wrong answer
195
#include <iostream>
#include <string>
int main() {
std::string s = "héllo";
auto second = s[1];
std::cout << int(second) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra main.cpp -o main && ./main! runs · wrong answer
195
What does the compiler say? Guess first, then open.
$ cargo buildWhat is E0277?
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:20
  |
3 |     let second = s[1];
  |                    ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  ... (notes pointing into the standard library trimmed)
What happens in Go

Go runs this and prints 195. s[1] is a single byte, the first half of é, not a character.

What happens in Kotlin

Kotlin runs this and prints é. Kotlin indexes UTF-16 units, and é is one unit, so you get the whole character.

What happens in Python

Python prints é. A Python str is indexed by characters (code points), not bytes, so s[1] gives a whole character.

What happens in TypeScript

Prints é. s[1] picks a UTF-16 unit, and é fits in one, so it is a whole character here.

What happens in Java

charAt(1) gives é here because Java strings are made of UTF-16 units, and é fits in one.

What happens in C#

C# strings are indexed by UTF-16 char, not by byte, and é fits in one, so s[1] is é.

What happens in C

C indexes bytes, and s[1] is the first of the two bytes in é. Whether char is signed depends on the platform. These outputs come from Linux on ARM64, where it is unsigned, so this prints 195. On x86 and on Apple-silicon Macs it is signed, and the same program prints -61. Either way you get a byte, not é.

What happens in C++

s[1] is the first byte of the two-byte é. These outputs come from Linux on ARM64, where char is unsigned, so this prints 195 like Go. On x86 and on Apple-silicon Macs char is signed, and the same program prints -61. Either way you get a byte, not é.

What Rust sees

Rust stores text as UTF-8, where one character can be 1 to 4 bytes. Byte 1 of "héllo" is the first half of é.

Why Rust says no

s[1] could mean byte 1 (half of é) or character 1. Look at the outputs: the same line gives a raw byte in some languages (Go, C, C++) and a character or UTF-16 unit in others (Python, Java, Kotlin, C#, TypeScript). Rust won't guess which you meant. Ask for characters with .chars(), or raw bytes with .as_bytes().

The fix

Compiles and runs.

Rust, fixedsrc/main.rsRun ↗
fn main() {
let s = String::from("héllo");
let second = s.chars().nth(1); // Some('é'): characters, not bytes
let byte = s.as_bytes()[1]; // 195: the raw byte, if you want it
println!("{second:?} {byte}");
}
$ cargo run
Some('é') 195

The toolkit

Two string types, and which one to use

Almost every string you touch is one of two types.

String &str
What it is Text you own. It lives on the heap and can grow. A view into text someone else owns. Read-only.
Where you see it Struct fields, return values, anything built at runtime Function parameters, string literals, pieces of other strings
Costs Allocates once it holds text, and when it grows Free: it is a pointer and a length

The rule that settles most decisions: take &str, store String, return String. A function that takes &str accepts every kind of text, because a &String turns into a &str on its own, and a slice of a String is already one:

Rustsrc/main.rsRun ↗
// A function that only reads text takes &str. It then accepts every kind of string.
fn shout(text: &str) -> String {
text.to_uppercase()
}
// A struct that keeps text owns it: String.
struct User {
name: String,
}
fn main() {
let literal = "hello"; // &'static str: baked into the program
let owned = String::from("world"); // String: on the heap, yours to change
let user = User {
name: String::from("ana"),
};
println!("{}", shout(literal));
println!("{}", shout(&owned)); // &String turns into &str by itself
println!("{}", shout(&user.name));
println!("{}", shout(&owned[1..3])); // a slice of a String is a &str too
}
$ cargo run
HELLO
WORLD
ANA
OR

Quoted text like "hello" is a &'static str: the text is baked into your program, and 'static says it lasts as long as the program does. You’ll meet that 'static again in error messages; it is nothing to worry about here.

Building strings

String::from("…") and "…".to_string() turn text into an owned String, and they do the same thing. You’ll also see "…".to_owned() and "…".into(), which do it too.

Rustsrc/main.rsRun ↗
fn main() {
// Two ways to make an owned String from text. They do the same thing.
let a = String::from("one");
let b = "two".to_string();
println!("{a} {b}");
// Grow one in place.
let mut s = String::new();
s.push_str("Hello");
s.push(','); // push takes a single char
s.push_str(" world");
println!("{s}");
// format! builds a new String from any mix of values, like println! without printing.
let user = "ana";
let visits = 3;
let line = format!("{user} has visited {visits} times");
println!("{line}");
// Joining a list.
let words = ["red", "green", "blue"];
println!("join: {}", words.join(", "));
println!("concat: {}", words.concat());
// Building in a loop: reserve the space once if you know roughly how much.
let mut csv = String::with_capacity(64);
for n in 1..=5 {
if !csv.is_empty() {
csv.push(',');
}
csv.push_str(&n.to_string());
}
println!("{csv}");
}
$ cargo run
one two
Hello, world
ana has visited 3 times
join: red, green, blue
concat: redgreenblue
1,2,3,4,5
  • push_str adds text and push adds one char. Both change the string, so it must be let mut.
  • format! is println! that returns the String instead of printing it. It’s the usual way to build a string from several values, like fmt.Sprintf, f-strings or template literals.
  • join and concat glue a list of strings together.
  • String::with_capacity reserves room up front. You don’t need it often, but in a loop that builds a large string it saves the repeated growing.

Looking inside

These methods read the text and never change it:

Rustsrc/main.rsRun ↗
fn main() {
let path = "/home/ana/notes/rust.md";
println!("len: {}", path.len()); // length in bytes
println!("is_empty: {}", path.is_empty());
println!("contains(\"notes\"): {}", path.contains("notes"));
println!("starts_with('/'): {}", path.starts_with('/'));
println!("ends_with(\".md\"): {}", path.ends_with(".md"));
// find gives a byte position, wrapped in Option because it might not be there.
println!("find('/'): {:?}", path.find('/'));
println!("rfind('/'): {:?}", path.rfind('/'));
println!("find(\"python\"): {:?}", path.find("python"));
// Matching with a closure: the first digit, if any.
let code = "room-42b";
println!(
"first digit at: {:?}",
code.find(|c: char| c.is_ascii_digit())
);
// Count how often something appears.
println!("\"an\" in banana: {}", "banana".matches("an").count());
}
$ cargo run
len: 23
is_empty: false
contains("notes"): true
starts_with('/'): true
ends_with(".md"): true
find('/'): Some(0)
rfind('/'): Some(15)
find("python"): None
first digit at: Some(5)
"an" in banana: 2

find returns Option<usize> rather than -1 when there’s no match, so you can’t forget the “not found” case. The number is a byte position, which matters for non-English text. More on that below.

Most search methods accept a char, a &str, or a closure that tests each char, which saves you from writing a regular expression for simple jobs. For real regular expressions, use the regex crate.

Splitting and trimming

This is where most day-to-day text work happens: reading input, parsing lines, cleaning up values.

Rustsrc/main.rsRun ↗
fn main() {
// split gives an iterator of &str pieces. collect turns it into a Vec.
let parts: Vec<&str> = "a,b,,c".split(',').collect();
println!("split(','): {parts:?}");
// Whitespace of any kind and length, with no empty pieces.
let words: Vec<&str> = " the quick\tbrown\nfox ".split_whitespace().collect();
println!("split_whitespace: {words:?}");
// One line at a time (handles \n and \r\n).
for (i, line) in "first\nsecond\r\nthird".lines().enumerate() {
println!("{i}: {line}");
}
// Split once, at the first match: perfect for key=value.
if let Some((key, value)) = "port=8080".split_once('=') {
println!("key={key} value={value}");
}
// At most n pieces.
let v: Vec<&str> = "2026-10-24T10:30".splitn(2, 'T').collect();
println!("splitn(2, 'T'): {v:?}");
// Trimming returns a smaller view of the same text: nothing is copied.
let raw = " padded \n";
println!("trim: {:?}", raw.trim());
println!("trim_start: {:?}", raw.trim_start());
println!("trim_end: {:?}", raw.trim_end());
println!("trim_matches('x'): {:?}", "xxhixx".trim_matches('x'));
// strip_prefix / strip_suffix: Some(rest) if it was there, None if not.
println!("strip_prefix on v1.2.3: {:?}", "v1.2.3".strip_prefix('v'));
println!("strip_prefix on 1.2.3: {:?}", "1.2.3".strip_prefix('v'));
}
$ cargo run
split(','): ["a", "b", "", "c"]
split_whitespace: ["the", "quick", "brown", "fox"]
0: first
1: second
2: third
key=port value=8080
splitn(2, 'T'): ["2026-10-24", "10:30"]
trim: "padded"
trim_start: "padded \n"
trim_end: "   padded"
trim_matches('x'): "hi"
strip_prefix on v1.2.3: Some("1.2.3")
strip_prefix on 1.2.3: None

A few things to know:

  • split returns an iterator, not a list. Loop over it directly, or collect() it into a Vec when you need one. The iterators lesson shows what else you can do with it.
  • Splitting and trimming copy nothing. Each piece is a &str pointing into the original text, so they are cheap even on large input.
  • Use split_whitespace rather than split(' ') for words: it handles tabs, newlines and repeated spaces, and never gives empty pieces.
  • split_once is the tidy way to handle key=value. It returns Option<(&str, &str)>, so a line without = is handled, not ignored.
  • strip_prefix returns None when the prefix isn’t there, unlike trim_start_matches, which quietly returns the text unchanged.
  • Working with Text covers the rest of the everyday toolkit: parse, case and the Unicode traps.

Changing text

Methods that produce a new version of the text return a new String. Methods that change it in place need a mut String.

Rustsrc/main.rsRun ↗
fn main() {
// These return a new String and leave the original alone.
let title = "Rust for Gophers";
println!("replace: {}", title.replace("Gophers", "everyone"));
println!("to_lowercase: {}", title.to_lowercase());
println!("to_uppercase: {}", title.to_uppercase());
println!("straße upper: {}", "straße".to_uppercase()); // ß becomes SS
// These change a String in place, so it must be `mut`.
let mut s = String::from("hello world");
s.insert(0, '>');
s.insert_str(1, "> ");
println!("after insert: {s:?}");
println!("pop returned: {:?}", s.pop()); // removes and returns the last char
s.truncate(5); // keep the first 5 bytes
println!("after truncate(5): {s:?}");
s.retain(|c| c != '>'); // keep only what the closure says yes to
println!("after retain: {s:?}");
s.clear();
println!("after clear: {s:?}");
// Comparing.
println!("\"Rust\" == \"rust\": {}", "Rust" == "rust");
println!("ignoring case: {}", "Rust".eq_ignore_ascii_case("rust"));
println!("\"apple\" vs \"banana\": {:?}", "apple".cmp("banana"));
let mut names = vec!["bo", "Cy", "ana"];
names.sort(); // byte order: every capital comes before every lowercase letter
println!("sort: {names:?}");
names.sort_by_key(|n| n.to_lowercase()); // what a person expects
println!("sort_by_key(lowercase): {names:?}");
}
$ cargo run
replace: Rust for everyone
to_lowercase: rust for gophers
to_uppercase: RUST FOR GOPHERS
straße upper: STRASSE
after insert: ">> hello world"
pop returned: Some('d')
after truncate(5): ">> he"
after retain: " he"
after clear: ""
"Rust" == "rust": false
ignoring case: true
"apple" vs "banana": Less
sort: ["Cy", "ana", "bo"]
sort_by_key(lowercase): ["ana", "bo", "Cy"]

Two things here surprise people:

  • to_uppercase understands Unicode: "straße" becomes "STRASSE", one character turning into two. That’s correct German, and it’s why case conversion returns a new String rather than changing the old one in place.
  • Comparing and sorting Strings compares bytes, so every capital letter sorts before every lowercase one. For human-friendly order, sort by a lowercased key (or use a collation crate for real locale rules).

Converting to and from strings

Rustsrc/main.rsRun ↗
fn main() {
// Text to a number: parse. It returns a Result, because the text might not be one.
let n: i32 = "42".parse().unwrap();
let price = "9.99".parse::<f64>().unwrap(); // or name the type with ::<>
println!("n + 1 = {}, price * 2 = {}", n + 1, price * 2.0);
match "forty-two".parse::<i32>() {
Ok(n) => println!("got {n}"),
Err(e) => println!("not a number: {e}"),
}
// A number (or anything that implements Display) to text.
let s = 3.5.to_string();
println!("to_string: {s:?}");
println!(
"format!: {:?}",
format!("{:>8.2}|{:<6}|{:06}", 3.14159, "left", 42)
);
// Bytes and text. Every String is valid UTF-8, so bytes that aren't get checked.
println!("as_bytes: {:?}", "hé".as_bytes());
println!(
"from_utf8 (good): {:?}",
String::from_utf8(vec![104, 195, 169])
);
println!(
"from_utf8 (bad) is_err: {}",
String::from_utf8(vec![104, 255]).is_err()
);
println!(
"from_utf8_lossy: {}",
String::from_utf8_lossy(&[104, 255, 105])
);
// Reversing is a job for chars, not bytes.
let reversed: String = "héllo".chars().rev().collect();
println!("reversed: {reversed}");
}
$ cargo run
n + 1 = 43, price * 2 = 19.98
not a number: invalid digit found in string
to_string: "3.5"
format!: "    3.14|left  |000042"
as_bytes: [104, 195, 169]
from_utf8 (good): Ok("hé")
from_utf8 (bad) is_err: true
from_utf8_lossy: h�i
reversed: olléh
  • parse turns text into any type that knows how: numbers, bool, IP addresses, and your own types if you implement FromStr. It returns a Result, so a bad value is an error you handle, not an exception or a silent zero.
  • to_string() works on anything that implements Display, which is what {} uses. The {:>8.2} mini-language is in Macros, attributes, naming, and the formatting lesson covers the traits and Formatter behind it.
  • A String is always valid UTF-8. Turning bytes into one is checked (String::from_utf8 returns a Result), or lossy if you ask (from_utf8_lossy puts � where bytes are invalid).

Your string methods in Rust

Pick your language in “Coming from” at the top of the page to see how the calls you already know map to Rust.

Characters, bytes and what a person sees

Here is the part most languages let you ignore until it bites, and Rust makes you face up front.

Rustsrc/main.rsRun ↗
fn main() {
let word = "naïve";
println!("{} bytes, {} chars", word.len(), word.chars().count());
// chars: Unicode scalar values. bytes: the raw UTF-8.
let chars: Vec<char> = word.chars().collect();
let bytes: Vec<u8> = word.bytes().collect();
println!("chars: {chars:?}");
println!("bytes: {bytes:?}");
// char_indices: each char with the byte position it starts at.
let indices: Vec<(usize, char)> = word.char_indices().collect();
println!("char_indices: {indices:?}");
// The nth character is a walk, not a jump: O(n).
println!("chars().nth(2): {:?}", word.chars().nth(2));
// Slicing uses byte positions. get() returns None instead of panicking
// when the range would cut a character in half.
println!("get(0..2): {:?}", word.get(0..2));
println!("get(0..3): {:?}", word.get(0..3));
println!("get(0..4): {:?}", word.get(0..4));
// A char is always 4 bytes in memory, whatever it encodes to in a string.
println!("size of a char: {}", size_of::<char>());
// One thing a person sees can be several chars: this flag is two, in 8 bytes.
let flag = "🇵🇹";
println!("flag: {} chars, {} bytes", flag.chars().count(), flag.len());
}
$ cargo run
6 bytes, 5 chars
chars: ['n', 'a', 'ï', 'v', 'e']
bytes: [110, 97, 195, 175, 118, 101]
char_indices: [(0, 'n'), (1, 'a'), (2, 'ï'), (4, 'v'), (5, 'e')]
chars().nth(2): Some('ï')
get(0..2): Some("na")
get(0..3): None
get(0..4): Some("naï")
size of a char: 4
flag: 2 chars, 8 bytes

A Rust string is a sequence of bytes in UTF-8. On top of those bytes you can view it as chars (Unicode scalar values). A person, though, sees graphemes: the flag above is one symbol on screen, made of two chars, which take eight bytes. Three different answers to “how long is this string?”, and all three are correct.

You want Use
Size in memory, or a byte position from find .len(), byte ranges
Characters, for processing text .chars(), .char_indices()
What a person would call a letter (for cursors, truncating names, counting user input) the unicode-segmentation crate, shown in Finding and using crates

Slicing with &text[a..b] uses byte positions. If a position lands inside a multi-byte character, the program stops with a clear message rather than giving you half a character:

Rustsrc/main.rs! panics at runtimeRun ↗
fn main() {
let word = "naïve";
// ï takes bytes 2 and 3. Ending a slice at byte 3 cuts it in half.
let start = &word[0..3];
println!("{start}");
}
$ cargo run
thread 'main' (1) panicked at src/main.rs:4:22:
end byte index 3 is not a char boundary; it is inside 'ï' (bytes 2..4 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

When the positions come from somewhere you don’t control, use text.get(a..b), which returns None instead of panicking. Positions you got from find or char_indices are always safe to slice at.

Going deeper

What a String is in memory

A String is three numbers on the stack: a pointer to the text on the heap, its length, and how much room is reserved (its capacity). A &str is just two: a pointer and a length. That’s why slicing is free: the slice points into text that already exists.

let s = String::from("héllo"); let v: &str = &s[0..3];
STACKHEAPs: Stringptrlen 6cap 6v: &strptrlen 36 bytes of UTF-8hé ①é ②llo
é takes two bytes, so héllo is 5 characters but 6 bytes. v points at the same bytes as s: the first three, hé. Nothing was copied.

You can check every part of that picture:

Rustsrc/main.rsRun ↗
fn main() {
let s = String::from("héllo");
let v: &str = &s[0..3];
println!("s: len {} capacity {}", s.len(), s.capacity());
println!("v: {v:?}, len {}", v.len());
// Same address: v points into s's bytes. Nothing was copied.
println!("same start: {}", v.as_ptr() == s.as_ptr());
println!(
"size on the stack: String {} bytes, &str {} bytes",
size_of::<String>(),
size_of::<&str>()
);
}
$ cargo run
s: len 6 capacity 6
v: "hé", len 3
same start: true
size on the stack: String 24 bytes, &str 16 bytes

When s goes out of scope, Rust frees the heap bytes. While v exists, the borrow checker won’t let s be changed or dropped, because v would be left pointing at freed or moved memory. That is the borrowing rule doing its job on text.

Why s[0] doesn’t compile

Rustsrc/main.rs✗ won't compile
fn main() {
let s = String::from("hello");
let first = s[0];
println!("{first}");
}
$ cargo buildWhat is E0277?
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:19
  |
3 |     let first = s[0];
  |                   ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  ... (notes pointing into the standard library trimmed)

In most languages s[i] is a quick jump to position i. In UTF-8, characters are 1 to 4 bytes long, so finding the i-th character means walking from the start. Rust refuses to give s[0] a meaning that is either wrong (a byte, as in Go and C) or secretly slow (a walk, which [] should never be). So you say which one you mean: .bytes().nth(i), .chars().nth(i), or a byte range &s[a..b].

+ takes the left side

a + &b looks symmetrical, but it isn’t. It takes a by value, appends b to a’s buffer, and hands that buffer back. No new allocation is made when there’s room, which is why it’s designed this way. The cost is that a is gone afterwards:

Rustsrc/main.rs✗ won't compile
fn main() {
let hello = String::from("Hello, ");
let world = String::from("world");
let greeting = hello + &world;
println!("{greeting}");
println!("{hello}"); // hello was moved into greeting
}
$ cargo buildWhat is E0382?
error[E0382]: borrow of moved value: `hello`
 --> src/main.rs:6:16
  |
2 |     let hello = String::from("Hello, ");
  |         ----- move occurs because `hello` has type `String`, which does not implement the `Copy` trait
3 |     let world = String::from("world");
4 |     let greeting = hello + &world;
  |                    ----- value moved here
5 |     println!("{greeting}");
6 |     println!("{hello}"); // hello was moved into greeting
  |                ^^^^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
4 |     let greeting = hello.clone() + &world;
  |                         ++++++++

So use + when you’re done with the left side, and format! when you want to keep everything:

Rustsrc/main.rsRun ↗
fn main() {
let hello = String::from("Hello, ");
let world = String::from("world");
// + takes the left String by value and appends to its buffer. The right side
// is only read, so it is borrowed with &.
let greeting = hello + &world;
println!("{greeting}");
println!("{world}"); // still fine: world was only borrowed
// For more than two pieces, format! reads better and borrows everything.
let a = String::from("tic");
let b = String::from("tac");
let c = String::from("toe");
let all = format!("{a}-{b}-{c}");
println!("{all} ({a} is still here)");
}
$ cargo run
Hello, world
world
tic-tac-toe (tic is still here)

How this compares with your language

Language String type Stored as Indexing s[i] gives Can you change it?
Rust String / &str UTF-8, always valid doesn’t compile String: yes. &str: no
Go string bytes, usually UTF-8 a byte no (immutable)
Java, Kotlin String UTF-16 a UTF-16 unit no (immutable)
C# string UTF-16 a UTF-16 unit no (immutable)
Python str Unicode code points a code point no (immutable)
TypeScript string UTF-16 a UTF-16 unit no (immutable)
C char * bytes, ends with \0 a byte yes
C++ std::string bytes a byte yes

Rust is the only one where the type guarantees valid text, and the only one that won’t pick an answer to s[i] for you.

Other string types you’ll meet

You won’t need these on day one, but you’ll see them in signatures:

  • Cow<str>: “a &str or a String, whichever I had”. Functions that usually return their input unchanged but sometimes need to modify it use it to avoid a copy.
  • OsString / OsStr: file names and environment variables, which on most systems are not guaranteed to be valid UTF-8.
  • PathBuf / Path: file paths. Use these instead of joining strings with /.
  • &[u8] and b"bytes": raw bytes, when the data isn’t text.
  • Box<str> and Arc<str>: text that won’t change again, stored more compactly or shared between threads.

Practice

Try it

Each exercise is a small program with checks in main. Open it in the Playground, replace the todo!(), and run it until it prints all checks passed. A plain for loop is fine for all of them. The solutions also show a shorter version with iterator methods, which the iterators lesson explains.

Initials

Write initials, which turns "Ada Lovelace" into "AL". It must handle extra spaces and names that start with a non-English letter.

Starterexercises/initials.rs
/// "Ada Lovelace" -> "AL". Words are separated by any whitespace.
fn initials(name: &str) -> String {
todo!("take the first char of each word, uppercased")
}
fn main() {
assert_eq!(initials("Ada Lovelace"), "AL");
assert_eq!(initials(" grace brewster hopper "), "GBH");
assert_eq!(initials("Émilie du Châtelet"), "ÉDC");
assert_eq!(initials(""), "");
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/initials.solution.rsRun ↗
/// "Ada Lovelace" -> "AL". Words are separated by any whitespace.
fn initials(name: &str) -> String {
// This uses iterator methods from the Iterators lesson; a for loop works too
// (see initials_with_a_loop below).
name.split_whitespace()
.filter_map(|word| word.chars().next())
.flat_map(char::to_uppercase)
.collect()
}
/// The same thing with a plain for loop.
#[allow(dead_code)]
fn initials_with_a_loop(name: &str) -> String {
let mut out = String::new();
for word in name.split_whitespace() {
if let Some(first) = word.chars().next() {
// to_uppercase can give more than one char ('ß' becomes "SS").
for upper in first.to_uppercase() {
out.push(upper);
}
}
}
out
}
fn main() {
assert_eq!(initials("Ada Lovelace"), "AL");
assert_eq!(initials(" grace brewster hopper "), "GBH");
assert_eq!(initials("Émilie du Châtelet"), "ÉDC");
assert_eq!(initials(""), "");
println!("all checks passed");
}
$ cargo run
all checks passed

Parse a settings line

Turn "name = Ada; age=36 ;city= London" into a list of (key, value) pairs, trimming spaces, and skipping pieces that have no =.

Starterexercises/parse-settings.rs
/// Turn "name = Ada; age=36 ;city= London" into pairs, trimming spaces around every
/// key and value. Skip pieces with no '=' and empty pieces.
fn parse_settings(text: &str) -> Vec<(String, String)> {
todo!("split on ';', then split_once('=') and trim")
}
fn main() {
let got = parse_settings("name = Ada; age=36 ;city= London");
assert_eq!(
got,
vec![
("name".to_string(), "Ada".to_string()),
("age".to_string(), "36".to_string()),
("city".to_string(), "London".to_string()),
]
);
assert_eq!(
parse_settings(";;junk; a=b;"),
vec![("a".to_string(), "b".to_string())]
);
assert!(parse_settings("").is_empty());
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/parse-settings.solution.rsRun ↗
/// Turn "name = Ada; age=36 ;city= London" into pairs, trimming spaces around every
/// key and value. Skip pieces with no '=' and empty pieces.
fn parse_settings(text: &str) -> Vec<(String, String)> {
// This uses iterator methods from the Iterators lesson; a for loop works too
// (see parse_settings_with_a_loop below).
text.split(';')
.filter_map(|piece| piece.split_once('='))
.map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
.collect()
}
/// The same thing with a plain for loop.
#[allow(dead_code)]
fn parse_settings_with_a_loop(text: &str) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for piece in text.split(';') {
if let Some((key, value)) = piece.split_once('=') {
pairs.push((key.trim().to_string(), value.trim().to_string()));
}
}
pairs
}
fn main() {
let got = parse_settings("name = Ada; age=36 ;city= London");
assert_eq!(
got,
vec![
("name".to_string(), "Ada".to_string()),
("age".to_string(), "36".to_string()),
("city".to_string(), "London".to_string()),
]
);
assert_eq!(
parse_settings(";;junk; a=b;"),
vec![("a".to_string(), "b".to_string())]
);
assert!(parse_settings("").is_empty());
println!("all checks passed");
}
$ cargo run
all checks passed

Shorten text safely

Shorten text to at most max characters, adding … when it was cut. It must never panic, even on "🦀🦀🦀". This is the bug that breaks real apps when a user’s name isn’t English.

Starterexercises/truncate-chars.rs
/// Shorten `text` to at most `max` characters (not bytes), adding "…" when it was cut.
/// It must never panic, whatever the text.
fn truncate_chars(text: &str, max: usize) -> String {
todo!("find where character number `max` starts with char_indices")
}
fn main() {
assert_eq!(truncate_chars("hello", 10), "hello");
assert_eq!(truncate_chars("hello world", 5), "hello…");
assert_eq!(truncate_chars("naïve café", 4), "naïv…");
assert_eq!(truncate_chars("🦀🦀🦀", 2), "🦀🦀…");
assert_eq!(truncate_chars("", 3), "");
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/truncate-chars.solution.rsRun ↗
/// Shorten `text` to at most `max` characters (not bytes), adding "…" when it was cut.
/// It must never panic, whatever the text.
fn truncate_chars(text: &str, max: usize) -> String {
// char_indices finds the byte where character number `max` starts, if there is one.
// nth is an iterator method from the Iterators lesson; a for loop works too
// (see truncate_chars_with_a_loop below).
match text.char_indices().nth(max) {
Some((byte, _)) => format!("{}…", &text[..byte]),
None => text.to_string(),
}
}
/// The same thing with a plain for loop.
#[allow(dead_code)]
fn truncate_chars_with_a_loop(text: &str, max: usize) -> String {
let mut count = 0;
for (byte, _) in text.char_indices() {
if count == max {
// `byte` is where a character starts, so slicing here is safe.
return format!("{}…", &text[..byte]);
}
count += 1;
}
text.to_string()
}
fn main() {
assert_eq!(truncate_chars("hello", 10), "hello");
assert_eq!(truncate_chars("hello world", 5), "hello…");
assert_eq!(truncate_chars("naïve café", 4), "naïv…");
assert_eq!(truncate_chars("🦀🦀🦀", 2), "🦀🦀…");
assert_eq!(truncate_chars("", 3), "");
println!("all checks passed");
}
$ cargo run
all checks passed

Read more