Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Owned vs Borrowed: Removing Lifetimes by Design

Reference for rust-lifetime-annotation-advisor · 331 lines

Programming / Rustv1.0.0Updated OCT 21 20267 references6 scripts

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

TypeOwns?Size (64-bit)Mutable?Lifetime param?Typical use
&'a strno16 (ptr + len)noyesparameters, short-lived views, parsers
Stringyes24 (ptr + len + cap)yesnobuilding text, struct fields
Box<str>yes16no (fixed length)nomany immutable strings stored long-term
Cow<'a, str>maybe24 or 32 (compiler-dependent)via to_mut()yes"usually borrowed, sometimes rewritten"
Cow<'static, str>maybeas Cow<'a, str>via to_mut()noliterals and runtime strings in one field
Rc<str> / Arc<str>shared16nonointerned names, config, keys cloned often
Arc<String>shared8nonoavoid: double indirection; prefer Arc<str>
&'a [T] / Vec<T> / Box<[T]> / Arc<[T]>as above16 / 24 / 16 / 16as aboveas abovesame 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&strStringArc<str>Cow<str>
Create from a literalfreealloc + copyalloc + copyfree (Borrowed)
Create from a String you ownborrow, freemove, freealloc + copy (Arc::from(s))move into Owned, free
Clonecopy 16 bytesalloc + copyatomic incrementfree if Borrowed, alloc if Owned
Dropnothingfreeatomic decrement (+ free at zero)free if Owned
Send to another thread / taskonly if 'staticyesyesonly if 'static
Store in a long-lived structstruct gets 'ano lifetimeno 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.

WantParameter typeAccepts
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 textArc<str> or impl Into<Arc<str>>caller clones the Arc
maybe keep itCow<'_, 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.

rust
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 dataReturn type
part of an input&str / &[T] (elided or tied with 'a)
part of an input, sometimes modifiedCow<'_, str>
newly builtString / Vec<T>
a fixed set of literals&'static str
many items borrowing an inputimpl Iterator<Item = &str> + '_
shared with other ownersArc<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:

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

StructProblemOwning version
struct Config<'a> { name: &'a str, url: &'a str }lives for the whole program; every service struct now needs 'aname: String, url: String or Arc<str>
struct Request<'a> { body: &'a [u8] } passed to a spawned tasktokio::spawn needs 'staticbody: bytes::Bytes or Vec<u8>
struct AppError<'a> { path: &'a Path }errors propagate up with ? past the borrowed datapath: PathBuf
struct Cache<'a> { entries: HashMap<&'a str, Entry> }keys borrowed from what? cache outlives requestsHashMap<String, Entry> or HashMap<Arc<str>, Entry>
struct Session<'a, 'b, 'c>three lifetimes infect every callerown, 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.

rust
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("&lt;"),
            '>' => out.push_str("&gt;"),
            '&' => out.push_str("&amp;"),
            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 &lt; 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

rust
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 is Send.
  • For many small strings with heavy cloning, interning (a HashSet<Arc<str>> or crates such as lasso) 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.

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

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

rust
#[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 (serde with #[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 str fields only for genuinely static data such as names in a const table (LT008).
More in Rust

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.