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
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!();}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()}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()}héllo h go + rust bytes=6 chars=5 [h][é][l][l][o]
owned = "héllo" # str is a sequence of characters, and immutableview = 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()héllo h go + rust bytes=6 chars=5 [h][é][l][l][o]
const owned = "héllo"; // strings can't be changed in placeconst 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 bytesconst chars = [...owned].length; // real charactersconsole.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);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(); }}héllo h go + rust bytes=6 chars=5 [h][é][l][l][o]
using System.Text;
var owned = "héllo"; // strings never changeReadOnlySpan<char> view = owned.AsSpan(0, 1); // a view into it, no copy
var s = new StringBuilder("go"); // the growable kinds.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();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;}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";}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 usesString. - A
&Stringturns into a&strautomatically when you pass it, sofn 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. UseString::from("hi")or"hi".to_string()when you need an owned String.
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.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)}195
fun main() { val s = "héllo" val second = s[1] println(second)}é
s = "héllo"second = s[1]print(second)é
const s = "héllo";const second = s[1];console.log(second);é
public class Main { public static void main(String[] args) { String s = "héllo"; char second = s.charAt(1); System.out.println(second); }}é
var s = "héllo";var second = s[1];Console.WriteLine(second);é
#include <stdio.h>
int main(void) { const char *s = "héllo"; char second = s[1]; printf("%d\n", second); return 0;}195
#include <iostream>#include <string>
int main() { std::string s = "héllo"; auto second = s[1]; std::cout << int(second) << "\n";}195
What does the compiler say? Guess first, then open.
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)
Go runs this and prints 195. s[1] is a single byte, the first half of é, not a character.
Kotlin runs this and prints é. Kotlin indexes UTF-16 units, and é is one unit, so you get the whole character.
Python prints é. A Python str is indexed by characters (code points), not bytes, so s[1] gives a whole character.
Prints é. s[1] picks a UTF-16 unit, and é fits in one, so it is a whole character here.
charAt(1) gives é here because Java strings are made of UTF-16 units, and é fits in one.
C# strings are indexed by UTF-16 char, not by byte, and é fits in one, so s[1] is é.
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 é.
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 é.
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 é.
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.
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}");}Some('é') 195The 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:
// 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}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.
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}");}one two Hello, world ana has visited 3 times join: red, green, blue concat: redgreenblue 1,2,3,4,5
push_stradds text andpushadds onechar. Both change the string, so it must belet mut.format!isprintln!that returns theStringinstead of printing it. It’s the usual way to build a string from several values, likefmt.Sprintf, f-strings or template literals.joinandconcatglue a list of strings together.String::with_capacityreserves 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:
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());}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: 2find 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.
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'));}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: NoneA few things to know:
splitreturns an iterator, not a list. Loop over it directly, orcollect()it into aVecwhen you need one. The iterators lesson shows what else you can do with it.- Splitting and trimming copy nothing. Each piece is a
&strpointing into the original text, so they are cheap even on large input. - Use
split_whitespacerather thansplit(' ')for words: it handles tabs, newlines and repeated spaces, and never gives empty pieces. split_onceis the tidy way to handlekey=value. It returnsOption<(&str, &str)>, so a line without=is handled, not ignored.strip_prefixreturnsNonewhen the prefix isn’t there, unliketrim_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.
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:?}");}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_uppercaseunderstands Unicode:"straße"becomes"STRASSE", one character turning into two. That’s correct German, and it’s why case conversion returns a newStringrather 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
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}");}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éhparseturns text into any type that knows how: numbers,bool, IP addresses, and your own types if you implementFromStr. It returns aResult, so a bad value is an error you handle, not an exception or a silent zero.to_string()works on anything that implementsDisplay, which is what{}uses. The{:>8.2}mini-language is in Macros, attributes, naming, and the formatting lesson covers the traits andFormatterbehind it.- A
Stringis always valid UTF-8. Turning bytes into one is checked (String::from_utf8returns aResult), or lossy if you ask (from_utf8_lossyputs�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.
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());}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 bytesA 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:
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}");}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.
é 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:
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>() );}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
fn main() { let s = String::from("hello"); let first = s[0]; println!("{first}");}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:
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}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:
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)");}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&stror aString, 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]andb"bytes": raw bytes, when the data isn’t text.Box<str>andArc<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.
/// "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
/// "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");}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 =.
/// 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
/// 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");}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.
/// 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
/// 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");}all checks passed
Read more
Stringandstrin the standard library docs. Every method, each with a runnable example.- The Rust Book, chapter 8.2: Storing UTF-8 Encoded Text with Strings
- Rust by Example: Strings
std::fmt: everythingformat!andprintln!can do.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.