Most lifetime pain comes from structs that borrow when they could own. This reference gives the cost model for each string/slice representation, rules for when a struct should carry a lifetime, and refactorings that delete lifetime parameters without measurable cost.
Contents
- The representations
- Cost model
- Function parameters: accept the most general borrow
- Return values
- Struct fields: views vs values
- Cow in practice
- Arc
and Rc : shared immutable text - Rc/Arc as an escape from lifetime soup
- Refactoring a borrowing struct to an owning one
- When borrowing is worth it
- Checklist
The representations
| Type | Owns? | Size (64-bit) | Mutable? | Lifetime param? | Typical use |
|---|---|---|---|---|---|
&'a str | no | 16 (ptr + len) | no | yes | parameters, short-lived views, parsers |
String | yes | 24 (ptr + len + cap) | yes | no | building text, struct fields |
Box<str> | yes | 16 | no (fixed length) | no | many immutable strings stored long-term |
Cow<'a, str> | maybe | 24 or 32 (compiler-dependent) | via to_mut() | yes | "usually borrowed, sometimes rewritten" |
Cow<'static, str> | maybe | as Cow<'a, str> | via to_mut() | no | literals and runtime strings in one field |
Rc<str> / Arc<str> | shared | 16 | no | no | interned names, config, keys cloned often |
Arc<String> | shared | 8 | no | no | avoid: double indirection; prefer Arc<str> |
&'a [T] / Vec<T> / Box<[T]> / Arc<[T]> | as above | 16 / 24 / 16 / 16 | as above | as above | same trade-offs for slices |
Sizes are for the handle; heap bytes come on top. Check exact sizes with std::mem::size_of rather than
relying on layout.
Cost model
| Operation | &str | String | Arc<str> | Cow<str> |
|---|---|---|---|---|
| Create from a literal | free | alloc + copy | alloc + copy | free (Borrowed) |
Create from a String you own | borrow, free | move, free | alloc + copy (Arc::from(s)) | move into Owned, free |
| Clone | copy 16 bytes | alloc + copy | atomic increment | free if Borrowed, alloc if Owned |
| Drop | nothing | free | atomic decrement (+ free at zero) | free if Owned |
| Send to another thread / task | only if 'static | yes | yes | only if 'static |
| Store in a long-lived struct | struct gets 'a | no lifetime | no lifetime | 'a unless 'static |
An allocation is tens of nanoseconds. A lifetime parameter costs every user of the type: every function that mentions it, every struct that contains it, every trait object that holds it. In application code (services, CLIs, agents) clone first; borrow in measured hot paths and in libraries whose whole point is zero-copy.
Function parameters: accept the most general borrow
Parameters should usually borrow, because borrowing never forces the caller to give anything up.
| Want | Parameter type | Accepts |
|---|---|---|
| read text | &str | &String (deref coercion), literals, slices |
| read a list | &[T] | &Vec<T>, arrays, slices |
| read a path | &Path or impl AsRef<Path> | &str, String, PathBuf, &Path |
| keep the text (store it) | String or impl Into<String> | caller decides whether to clone or move |
| keep shared text | Arc<str> or impl Into<Arc<str>> | caller clones the Arc |
| maybe keep it | Cow<'_, str> / impl Into<Cow<'a, str>> | literals without allocation |
Taking String when you store the value is not wasteful: if the caller has a String they move it (no copy);
if they have a &str, they write .to_owned() and the cost is visible. Taking &str and cloning internally
hides an allocation and forces a copy even when the caller could have moved.
pub struct User {
name: String,
}
impl User {
// Caller moves a String in, or passes "literal" and pays one allocation.
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
pub fn name(&self) -> &str {
&self.name
}
}
fn main() {
let a = User::new("ada");
let b = User::new(String::from("grace"));
println!("{} {}", a.name(), b.name());
}Return values
| Returned data | Return type |
|---|---|
| part of an input | &str / &[T] (elided or tied with 'a) |
| part of an input, sometimes modified | Cow<'_, str> |
| newly built | String / Vec<T> |
| a fixed set of literals | &'static str |
| many items borrowing an input | impl Iterator<Item = &str> + '_ |
| shared with other owners | Arc<str> / Arc<T> |
Never return a reference to a local (E0515). If you are tempted to add a lifetime to make it compile, return the owned value instead.
Struct fields: views vs values
A struct with a lifetime parameter is a view: it is valid only while what it borrows is alive, cannot be
returned past its source, cannot be sent to tokio::spawn, cannot be stored in a static, and every type that
contains it needs the parameter too.
Good views:
/// Created, used and dropped within one function call.
pub struct Lexer<'src> {
src: &'src str,
pos: usize,
}
/// A borrowed window over a buffer, like std::slice::Iter.
pub struct Rows<'a> {
data: &'a [f32],
width: usize,
}
impl<'a> Iterator for Rows<'a> {
type Item = &'a [f32];
fn next(&mut self) -> Option<&'a [f32]> {
if self.data.is_empty() || self.width == 0 {
return None;
}
let n = self.width.min(self.data.len());
let (row, rest) = self.data.split_at(n);
self.data = rest;
Some(row)
}
}Bad views (should own):
| Struct | Problem | Owning version |
|---|---|---|
struct Config<'a> { name: &'a str, url: &'a str } | lives for the whole program; every service struct now needs 'a | name: String, url: String or Arc<str> |
struct Request<'a> { body: &'a [u8] } passed to a spawned task | tokio::spawn needs 'static | body: bytes::Bytes or Vec<u8> |
struct AppError<'a> { path: &'a Path } | errors propagate up with ? past the borrowed data | path: PathBuf |
struct Cache<'a> { entries: HashMap<&'a str, Entry> } | keys borrowed from what? cache outlives requests | HashMap<String, Entry> or HashMap<Arc<str>, Entry> |
struct Session<'a, 'b, 'c> | three lifetimes infect every caller | own, or split into an owned core + short-lived view |
Error types in particular should be 'static so they can be boxed into Box<dyn Error + Send + Sync>,
anyhow::Error, or returned from main.
Cow in practice
Cow<'a, B> ("clone on write") holds either &'a B or B::Owned. It derefs to &B, so readers do not care
which variant they have.
use std::borrow::Cow;
/// Escape HTML only when needed: most inputs are returned borrowed (no allocation).
pub fn escape_html(input: &str) -> Cow<'_, str> {
if !input.contains(|c| matches!(c, '<' | '>' | '&')) {
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len() + 8);
for ch in input.chars() {
match ch {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
c => out.push(c),
}
}
Cow::Owned(out)
}
/// A field that accepts literals for free and runtime strings by value, without a lifetime.
pub struct Column {
pub name: Cow<'static, str>,
}
fn main() {
let a = escape_html("plain");
let b = escape_html("a < b");
assert!(matches!(a, Cow::Borrowed(_)));
assert_eq!(b, "a < b");
let fixed = Column { name: Cow::Borrowed("id") };
let dynamic = Column { name: Cow::Owned(format!("col_{}", 3)) };
println!("{} {}", fixed.name, dynamic.name);
}Useful Cow methods: to_mut() (clones into Owned on first write), into_owned() (get a String, cloning
only if borrowed), is_borrowed()/is_owned() are unstable, so match on the variant instead.
Cow keeps the lifetime parameter unless you use Cow<'static, str>. If a struct field is
Cow<'a, str> and the struct is long-lived, you have not removed the lifetime: call into_owned() at the
boundary.
Arc and Rc: shared immutable text
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct Model {
pub name: Arc<str>,
pub provider: Arc<str>,
}
fn main() {
let provider: Arc<str> = Arc::from("local");
let mut by_name: HashMap<Arc<str>, Model> = HashMap::new();
for raw in ["small", "large"] {
let name: Arc<str> = Arc::from(raw);
let model = Model { name: Arc::clone(&name), provider: Arc::clone(&provider) };
by_name.insert(name, model); // key and value share the same allocation
}
// HashMap<Arc<str>, _> can be queried with &str because Arc<str>: Borrow<str>.
println!("{:?}", by_name.get("small").map(|m| &*m.provider));
}Arc<str>is one allocation (refcounts + bytes) and a 16-byte handle;Arc<String>is two allocations.- Conversions:
Arc::<str>::from("lit"),Arc::<str>::from(string)(copies the bytes into the new allocation),Arc::<str>::from(cow). - Use
Rc<str>in single-threaded code (non-atomic counts),Arc<str>when anything isSend. - For many small strings with heavy cloning, interning (a
HashSet<Arc<str>>or crates such aslasso) turns string comparisons into pointer comparisons.
Rc/Arc as an escape from lifetime soup
When borrows form a web (A borrows B, which borrows C, and A must outlive the function that created C), shared ownership removes all of them at the cost of refcounting.
use std::sync::Arc;
pub struct Schema {
pub fields: Vec<String>,
}
// Before: struct Validator<'s> { schema: &'s Schema } and struct Pipeline<'s> { v: Validator<'s> }
// After: no lifetimes; validators can be stored, cloned, sent to threads.
#[derive(Clone)]
pub struct Validator {
schema: Arc<Schema>,
}
impl Validator {
pub fn new(schema: Arc<Schema>) -> Self {
Self { schema }
}
pub fn check(&self, field: &str) -> bool {
self.schema.fields.iter().any(|f| f == field)
}
}
fn main() {
let schema = Arc::new(Schema { fields: vec!["id".into(), "name".into()] });
let v = Validator::new(Arc::clone(&schema));
let handle = std::thread::spawn(move || v.check("id"));
assert!(handle.join().unwrap());
}Costs: an atomic increment per clone and decrement per drop (uncontended: a few nanoseconds), one extra pointer
hop on access, and shared data is immutable unless you add Mutex/RwLock/atomics. Cycles of Rc/Arc
leak; break them with Weak (parent pointers) or use indices.
Refactoring a borrowing struct to an owning one
Before:
pub struct Job<'a> {
pub id: &'a str,
pub tags: Vec<&'a str>,
}
pub fn parse_job(line: &str) -> Job<'_> {
let mut parts = line.split(',');
let id = parts.next().unwrap_or("");
Job { id, tags: parts.collect() }
}Every function storing a Job now needs 'a, and parse_job(&read_line()) fails with E0716 because the
line is a temporary. After:
#[derive(Clone, Debug)]
pub struct Job {
pub id: String,
pub tags: Vec<String>,
}
pub fn parse_job(line: &str) -> Job {
let mut parts = line.split(',');
let id = parts.next().unwrap_or("").to_owned();
Job { id, tags: parts.map(str::to_owned).collect() }
}Cost: one allocation per field. If profiling shows it matters, keep both: a borrowed JobRef<'a> for the hot
parse loop and impl JobRef<'_> { fn to_owned(&self) -> Job } at the storage boundary (the
serde_json::Value vs &RawValue, String vs &str pattern).
When borrowing is worth it
- Zero-copy parsers and deserialisers over large inputs (
serdewith#[serde(borrow)],nom, tokenizers). - Iterators and adaptors (
Iter<'a, T>,Chars<'a>,Split<'a, P>): they must borrow. - Guards and handles that release something on drop (
MutexGuard<'a, T>,Ref<'a, T>, transactions). - Hot loops where profiling shows allocation dominates.
- APIs returning parts of
self(fn name(&self) -> &str): borrow from self is free and elided.
Checklist
- Parameters borrow (
&str,&[T],&Path); parameters you store are owned (String,impl Into<String>). - Long-lived structs, errors, configs, messages and anything sent to threads/tasks own their data.
- Views (parsers, iterators, guards) borrow and carry exactly one lifetime where possible.
Cow<'static, str>for "literal or runtime string" fields;Arc<str>for shared immutable text.- More than one lifetime parameter on a struct is a design review item (LT010).
&'static strfields only for genuinely static data such as names in a const table (LT008).
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-tokio-async-service-builder
Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.