Polyglot
Languages Rust strings
Rust § strings

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:

  • &str for parameters when the function only reads the string.
  • String for owned values (return values that the caller takes ownership of, struct fields).
  • String literals ("hello") are &'static str&str references with the 'static lifetime.

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

MethodEffect
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-printed Debug.
  • {: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.

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 String for owned strings; &str for borrowed.
  • Take &str parameters when only reading; the function works with both &String and &str.
  • Return String when 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 the Result.

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.