Strings
Rust distinguishes two principal string types: String (owned, heap-allocated, growable, UTF-8) and &str (a borrowed string slice; non-owning, fixed-size, UTF-8). The distinction reflects Rust’s ownership model — String owns its data; &str borrows. Both are guaranteed UTF-8; indexing into a string by byte position is admitted but returns a byte slice (not a character), and indexing by character position requires explicit chars() iteration. The combination — UTF-8 by default, ownership-aware string types, no implicit byte-vs-character confusion — is one of the conventional Rust strengths and one of its principal initial confusions.
String and &str
The two types:
let owned: String = String::from("hello");
let borrowed: &str = "hello"; // string literal; static lifetime
let owned2: String = "hello".to_string();
let owned3: String = "hello".to_owned();
let borrowed2: &str = &owned; // borrow from String
fn process(s: &str) {
// accepts both String (via deref) and &str
}
process("hello"); // &str
process(&owned); // &String → &str via deref
The conventional choice:
&strfor parameters when the function only reads the string.Stringfor owned values (return values that the caller takes ownership of, struct fields).- String literals (
"hello") are&'static str—&strreferences with the'staticlifetime.
The Deref trait admits String to be used wherever &str is expected; the conversion is automatic.
String literals
"hello" // &'static str
"" // empty
"line1\nline2" // with escape
r"raw \n string" // raw; no escape processing
r#"with "quotes""# // raw with quotes
b"hello" // &[u8; 5]; byte string literal
// Multi-line:
"line1
line2" // includes the newline
// Concatenation at compile time:
concat!("hello", " ", "world")
The r"..." form admits raw strings; the r#"..."# form admits raw strings containing quotes (the number of # admits matching).
The b"..." produces a byte string &[u8; N]; for byte data, not text.
String operations
String is a wrapper around Vec<u8> with the invariant that the bytes are valid UTF-8. The principal operations:
let mut s = String::new();
s.push('a'); // append a char
s.push_str(" hello"); // append a string slice
s += " world"; // operator overload (+= calls push_str)
let s = format!("{} {}", "hello", "world");
let len = s.len(); // bytes (NOT characters)
let chars = s.chars().count(); // character count (linear)
let is_empty = s.is_empty();
s.clear(); // empty
s.truncate(10); // truncate to 10 bytes
The conventional construction methods:
String::new() // empty
String::with_capacity(100) // pre-allocate
String::from("hello") // from a literal
"hello".to_string() // method call
"hello".to_owned() // similar
format!("{}", value) // formatted
The String::with_capacity admits avoiding reallocation when the final size is known.
Indexing
String and &str cannot be indexed by byte position with []:
let s = String::from("hello");
let c = s[0]; // ERROR: indexing not admitted
The reason: a single byte may not be a complete character (UTF-8 is variable-length). Indexing by byte would admit producing invalid UTF-8.
The admitted indexing forms:
Slicing by byte range
let s = String::from("hello");
let slice: &str = &s[1..4]; // "ell"
The byte range must fall on character boundaries; otherwise the program panics.
Iterating by character
for c in s.chars() {
println!("{}", c);
}
let nth = s.chars().nth(0); // Option<char>; first char
let count = s.chars().count(); // character count
Iterating by byte
for b in s.bytes() {
println!("{}", b);
}
let bytes = s.as_bytes(); // &[u8]
For ASCII text, the byte and character counts are the same; for non-ASCII, they differ:
let s = "café";
s.len(); // 5 (bytes)
s.chars().count(); // 4 (characters)
Methods
The principal &str methods (also available on String via deref):
| Method | Effect |
|---|---|
s.len() | Number of bytes |
s.is_empty() | Whether the string is empty |
s.chars() | Iterator over char values |
s.bytes() | Iterator over u8 values |
s.lines() | Iterator over lines |
s.split(pattern) | Split by a delimiter |
s.split_whitespace() | Split by any whitespace |
s.trim(), s.trim_start(), s.trim_end() | Strip whitespace |
s.starts_with(p), s.ends_with(p) | Prefix/suffix tests |
s.contains(p) | Substring containment |
s.find(p), s.rfind(p) | Position; Option<usize> |
s.replace(from, to) | Replace all occurrences |
s.replacen(from, to, n) | Replace at most n |
s.to_uppercase(), s.to_lowercase() | Case conversion (returns String) |
s.repeat(n) | Repeated string |
s.parse::<T>() | Parse to a typed value; returns Result<T, E> |
let s = " Hello, world! ";
let trimmed = s.trim(); // "Hello, world!"
let upper = trimmed.to_uppercase(); // "HELLO, WORLD!"
let words: Vec<&str> = trimmed.split(", ").collect();
// ["Hello", "world!"]
let n: i32 = "42".parse().unwrap();
let n: Result<i32, _> = "abc".parse::<i32>(); // Err(...)
Formatting with format!
The format! macro produces a String:
let name = "alice";
let age = 30;
let s = format!("Hello, {}, age {}", name, age);
let s = format!("{name} is {age}"); // named (since 1.58)
let s = format!("{:width$}", value, width = 10); // dynamic width
let s = format!("{:>10}", "right"); // right-aligned
let s = format!("{:<10}", "left"); // left-aligned
let s = format!("{:^10}", "center"); // centred
let s = format!("{:0>5}", 42); // "00042"
let s = format!("{:.2}", 3.14159); // "3.14"
let s = format!("{:#x}", 255); // "0xff"
let s = format!("{:b}", 10); // "1010"
let s = format!("{:e}", 1234.5); // "1.2345e3"
The format syntax:
{}—Display(the user-facing form).{:?}—Debug(the developer-facing form).{:#?}— pretty-printedDebug.{:width$}— minimum width.{:.precision$}— precision.{:0>width}— fill character (0), alignment (>right), width.{:#x},{:#o},{:#b}— hex, octal, binary with prefix.{0},{name}— positional or named.
The full syntax is in std::fmt. Conventional uses cover most formatting needs; for more elaborate templating, third-party crates (heck, tinytemplate, handlebars) provide alternatives.
print!, println!, eprintln!, write!
The format!-family of output macros:
print!("hello"); // stdout, no newline
println!("hello"); // stdout with newline
eprintln!("error: {}", msg); // stderr with newline
// Write to any io::Write:
use std::io::Write;
let mut buffer: Vec<u8> = Vec::new();
write!(buffer, "{}", value).unwrap();
writeln!(buffer, "with newline").unwrap();
The write! macro writes to any io::Write; the same syntax as format! but the output is not a String.
Display and Debug
Two conventional traits for string conversion:
use std::fmt;
struct Point {
x: f64,
y: f64,
}
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
impl fmt::Debug for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Point {{ x: {:?}, y: {:?} }}", self.x, self.y)
}
}
let p = Point { x: 3.0, y: 4.0 };
println!("{}", p); // Display: "(3, 4)"
println!("{:?}", p); // Debug: "Point { x: 3.0, y: 4.0 }"
Debug is conventionally derived:
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}
Display must be implemented manually; the conventional discipline is to implement it when the type has a natural string representation.
&str and &[u8]
&str is a UTF-8-validated byte slice; &[u8] is a raw byte slice:
let s: &str = "hello";
let bytes: &[u8] = s.as_bytes(); // &[u8; 5]; the 5 bytes
let bytes: &[u8] = &[0xff, 0xfe];
let s = std::str::from_utf8(bytes); // Result<&str, Utf8Error>
The as_bytes is infallible; the inverse from_utf8 validates and returns a Result.
For UTF-16 or other encodings, the standard library provides OsStr (platform-native; not necessarily UTF-8 on all platforms), CStr (null-terminated C strings), and conversion functions. The conventional contemporary discipline is to use &str/String for text and &[u8]/Vec<u8> for raw bytes, with explicit conversions at the boundaries.
OsStr and OsString
For paths and OS-supplied strings (which may not be UTF-8 on Windows):
use std::path::Path;
use std::ffi::{OsStr, OsString};
let path = Path::new("/etc/passwd");
let s: &OsStr = path.as_os_str();
let s_owned: OsString = s.to_owned();
OsStr is the conventional path-component type. For most cross-platform path handling, the Path and PathBuf types (which wrap OsStr and OsString) are the conventional choice; treated in I/O.
CStr and CString
For interoperation with C code that uses null-terminated strings:
use std::ffi::{CStr, CString};
let c = CString::new("hello").unwrap();
let r: *const i8 = c.as_ptr();
// pass r to a C function
let c_back: &CStr = unsafe { CStr::from_ptr(c_ptr) };
let s: &str = c_back.to_str().unwrap();
CStr is the borrowed null-terminated form; CString is the owned form. The conventional uses are FFI; treated in std::ffi documentation.
Common patterns
Joining strings
let v = vec!["a", "b", "c"];
let joined: String = v.join(", "); // "a, b, c"
let mut s = String::new();
for x in &v {
if !s.is_empty() {
s.push_str(", ");
}
s.push_str(x);
}
The join is the conventional Rust form for delimited concatenation.
Building strings
let mut s = String::with_capacity(100);
for item in items {
s.push_str(&item.to_string());
s.push('\n');
}
For substantial string-building, pre-allocating with with_capacity and using push_str is faster than repeated concatenation.
Parsing numbers
let n: i32 = "42".parse().expect("not a number");
let n = "42".parse::<i32>().unwrap_or(0);
let result = "abc".parse::<i32>();
match result {
Ok(n) => println!("got {}", n),
Err(e) => eprintln!("parse error: {}", e),
}
The parse() returns Result<T, T::Err>; the type is determined by context or by the turbofish.
Iterating words
let s = "hello world from rust";
for word in s.split_whitespace() {
println!("{}", word);
}
split_whitespace admits multi-whitespace separation; split(' ') admits exactly one space per separator.
Iterating lines
let s = "line 1\nline 2\nline 3";
for line in s.lines() {
println!("{}", line);
}
lines handles both \n and \r\n line endings.
Replacing substrings
let s = "hello, world".replace(",", ";"); // "hello; world"
let s = s.replacen(",", ";", 1); // replace at most 1
Lowercase/uppercase
let s = "Hello".to_uppercase(); // "HELLO"
let s = "Hello".to_lowercase(); // "hello"
For Unicode-aware case folding (case-insensitive comparison), the unicase crate provides additional facilities.
Searching
let s = "hello world";
let pos = s.find("world"); // Some(6)
let pos = s.find("xyz"); // None
let pos = s.rfind('o'); // Some(7) (last)
let count = s.matches("l").count(); // 3
let starts = s.starts_with("hello"); // true
let ends = s.ends_with("world"); // true
let contains = s.contains("o w"); // true
The methods admit substring search; for substantial pattern matching, the regex crate provides regular expressions.
Encoding and UTF-8
Rust enforces UTF-8 throughout; conversions to and from bytes are explicit:
let s = "hello, 世界";
let bytes = s.as_bytes(); // &[u8]
let v: Vec<u8> = s.into_bytes(); // owned
let bytes: Vec<u8> = vec![104, 101, 108, 108, 111];
let s = String::from_utf8(bytes).unwrap(); // "hello"
let s = std::str::from_utf8(&[104, 105]).unwrap(); // "hi"
// Lossy conversion (substitutes invalid bytes):
let s = String::from_utf8_lossy(&[0xff, 0xfe, b'h', b'i']);
// "ヷhi" or similar; invalid bytes become U+FFFD
The _lossy form replaces invalid bytes with U+FFFD (the replacement character); useful when the bytes may be corrupted.
For non-UTF-8 encodings, third-party crates (encoding_rs) provide conversion to and from a substantial set of character encodings.
A note on the conventional discipline
The contemporary Rust string advice:
- Use
Stringfor owned strings;&strfor borrowed. - Take
&strparameters when only reading; the function works with both&Stringand&str. - Return
Stringwhen the function produces an owned value. - Use
format!for formatted strings;println!for output. - Don’t index by byte position unless you have verified the byte position falls on a character boundary.
- Iterate
chars()for character-level access;bytes()for byte-level. - Use
parse()for string-to-value conversion; check theResult.
The combination — owned vs borrowed strings, UTF-8 throughout, explicit indexing semantics — is the substance of Rust’s string handling. The discipline of choosing String vs &str is part of fluency in the language.