Home

Lifetimes in structs: when to borrow and when to own

The first time I wrote a lifetime annotation on a struct, it was because the compiler asked for one and I did what it said. That produced a type that was miserable to use, and the fix was to stop borrowing.

What the annotation means

struct Parser<'a> {
    input: &'a str,
    position: usize,
}

'a is not a property of the struct. It is a constraint on where the struct may exist: this Parser cannot outlive the string it points into. Every function taking one, every struct containing one, and every return type mentioning one inherits that constraint.

That is the cost, and it is contagious. A single borrowed field in a type near the bottom of your program propagates lifetime annotations up through everything that touches it.

When borrowing is right

Two situations, both narrow:

A short-lived view over data someone else owns. A parser or an iterator that exists inside one function call and never escapes it:

fn count_words(text: &str) -> usize {
    let parser = Parser { input: text, position: 0 };   // never leaves this scope
    parser.count()
}

The lifetime is invisible here because it is inferred and never crosses a boundary.

A hot path where the copy genuinely costs. If you are constructing millions of these in a loop and each one would clone a large string, borrowing is worth the annotation. Measure first — this is much rarer than the Rust-community instinct suggests.

When to own instead

Everywhere else, and especially in these three cases.

Anything stored in a struct that lives beyond one function. A configuration, a cached value, a piece of application state:

// painful: every holder of Config now needs a lifetime
struct Config<'a> {
    name: &'a str,
    url: &'a str,
}

// fine
struct Config {
    name: String,
    url: String,
}

Anything crossing a thread or an async boundary. A borrowed field means the struct is not 'static, and tokio::spawn requires 'static. You will fight this and lose.

Anything in a public API. A lifetime in your signature is a constraint you are imposing on every caller, forever. Owning the data lets them do whatever they want.

Tip

The heuristic: if the struct is a noun in your program’s domain — Config, User, Request — own the data. If it is a temporary verb — Parser, Iterator, Visitor — borrowing is reasonable. Domain types outlive the things they were built from; temporary machinery does not.

What owning actually costs

This is the part that made me stop over-thinking it.

A String field instead of a &str costs one heap allocation and a memcpy of the bytes, at construction time. For a config loaded once at startup, that is nanoseconds you will never measure. For a struct built per HTTP request, it is a rounding error next to the network I/O that request already did.

The cases where it genuinely matters are tight loops running millions of iterations. Those exist, and they are exactly the places a profiler points at immediately. Writing lifetime-annotated code everywhere to avoid an allocation nobody measured is premature optimisation with extra syntax and worse ergonomics.

Cow for when you genuinely cannot decide

Cow<'a, str> — clone on write — holds either a borrow or an owned value, and only allocates if something needs to change it:

use std::borrow::Cow;

fn normalise(input: &str) -> Cow<'_, str> {
    if input.contains('\t') {
        Cow::Owned(input.replace('\t', "    "))    // allocated, because we changed it
    } else {
        Cow::Borrowed(input)                        // free, nothing to change
    }
}

This is genuinely useful for a function that usually returns its input unchanged. It is also frequently reached for too early — it still carries a lifetime, so it does not solve the contagion problem, and it adds a branch at every use site.

The rule I use now

Start by owning everything. String, Vec<T>, PathBuf. Write the program.

Then, if a profiler shows a hot path dominated by cloning, introduce a borrow there, in the narrowest scope that fixes it. Usually that means a function taking &str instead of String — which needs no struct lifetime at all, because a function parameter’s lifetime is inferred.

That last point is worth stating clearly, because it is where the confusion comes from: taking &str as a parameter is free and idiomatic. Storing &str in a struct is a design decision with real consequences. They look similar and they are not the same thing, and conflating them is what had me writing <'a> on types that should never have had one.