The Rust Engineering Handbook / Chapter 8
Lifetimes as Relationships
Read lifetime parameters as validity relationships and design borrowed APIs without reflexive static bounds.
A borrowed answer needs a source
fn choose_account(left: &Entry, right: &Entry) -> &str
Suppose a routing rule must choose the account with the larger balance and return its name. The signature is incomplete. The result is borrowed, but the type does not say whether left or right validates it. Duration in seconds is irrelevant; provenance is missing.
A lifetime parameter names that relationship in the type system. A borrowed output cannot be used beyond the validity supplied by its source. An annotation does not extend storage, keep an owner alive, or schedule destruction.
The compiler is asking an ownership question: which owner must still be available when the caller uses the answer? Adding 'static until the selector compiles evades that question. It can force callers to leak strings or manufacture owned copies for a decision that lasts one request, while still failing to describe the result’s actual source.
Local code usually supplies enough evidence
Inside a function, Rust usually infers where a borrow must remain valid:
let account = String::from("cash");
let view = &account;
println!("{view}");
The compiler sees that view is used while account is still valid. You do not write local lifetime lengths, and annotations would not make account live longer. Inferred regions may stop constraining later code before a lexical block ends when the reference has no later use.
Function and type boundaries require explicit relationships when callers need them. This is analogous to making an ownership or error contract visible: the implementation alone cannot be the public specification.
Elision handles the one-source case
This function returns a view into its input:
fn account(entry: &Entry) -> &str {
&entry.account
}
Elision makes it equivalent in relationship to:
fn account<'entry>(entry: &'entry Entry) -> &'entry str {
&entry.account
}
The name 'entry sets no duration. It says that, for a valid input borrow chosen by the caller, the returned reference is tied to that borrow. At a call site, the compiler can constrain use of the result to a smaller region.
Elision covers common signatures. Each elided input reference receives a lifetime parameter. If there is exactly one input lifetime, it is assigned to elided outputs. In methods, the receiver lifetime is assigned to elided outputs. These are signature rules, not guesses based on the function body. Explicit names can still help review when the relationship deserves attention.
Two possible sources force the contract into view
The following signature is ambiguous:
fn choose_account(left: &Entry, right: &Entry) -> &str {
if left.cents >= right.cents {
&left.account
} else {
&right.account
}
}
There are two input relationships and no elision rule that can select the output source. The body cannot repair an incomplete public contract. Because either account may supply the result, one honest signature is:
fn choose_account<'entries>(
left: &'entries Entry,
right: &'entries Entry,
) -> &'entries str
Read 'entries as a constraint variable. The returned name can be used only within a region supported by both input borrows, because the function may choose either one. The signature does not claim that the owners were created together, have equal scopes, or will be destroyed together.
That distinction becomes visible when one candidate is short-lived:
let cash = Entry::new("cash", 80);
let selected;
{
let fees = Entry::new("fees", 20);
selected = choose_account(&cash, &fees);
println!("route to {selected}"); // both possible sources are valid here
}
// selected cannot be used here: it might have named `fees`
The annotation did not shorten cash or extend fees. It described the overlap in which the answer is valid.
Now add a display label supplied by the caller. The account view and the label have different owners, so the result should preserve their independence:
fn annotate<'entry, 'label>(
entry: &'entry Entry,
label: &'label str,
) -> (&'entry str, &'label str) {
(&entry.account, label)
}
Using one lifetime for both would invent coupling. An unrelated short label could then constrain the account view. Lifetime design includes refusing relationships the implementation does not need.
Figure 8-1 is a constraint graph, not a countdown. The output region must fit inside the validity supplied by its source, while the separate 'static panel distinguishes a program-long reference from an owned value with no borrowed dependency.
Packaging the answer does not change its owner
struct EntryView<'entry> {
account: &'entry str,
cents: i64,
}
An EntryView<'entry> still borrows the account string from an Entry; putting the reference in a struct does not transfer ownership. The view cannot be used after its source relationship ends. It is cheap to construct and requires no string allocation, but that economy couples every consumer to the owner’s lifecycle.
That trade is often right inside one synchronous operation. It becomes awkward when the router wants to cache a decision, persist it, put it on a task queue, or retry it after the ledger snapshot has been replaced. An owned EntrySnapshot copies or moves its String but can travel independently. An index or handle keeps ownership in a repository and makes lookup plus stale-handle behavior part of the design. Cow<'a, str> permits either a borrowed fast path or an owned result, at the cost of exposing two states to callers.
Choose the form at the lifecycle boundary. A borrowed view is not automatically the efficient option once it causes a larger owner to remain retained or forces lifetime parameters through several architectural layers.
Generic bounds constrain what a value may borrow
A bound such as T: 'a means any references contained in T must remain valid for at least 'a. It does not require T to be a reference. This matters in generic containers and trait objects:
struct Slot<'a, T: 'a> {
value: T,
label: &'a str,
}
Modern Rust infers some outlives bounds from fields, so explicit bounds should communicate a real public relationship rather than decorate every generic parameter. When a diagnostic mentions T: 'a, ask which references T may contain and why those dependencies must remain valid for 'a.
The same question appears in trait objects. Box<dyn Handler + 'a> permits a handler that borrows data valid for 'a. Box<dyn Handler + 'static> excludes handlers with shorter borrowed dependencies. A process-wide registry may need that restriction; a router that invokes handlers within one request does not.
'static describes two different boundaries
These types make different claims:
let name: &'static str = "cash";
fn spawnable<T: 'static>(value: T) {
// value may be owned and still dropped when this function returns
}
&'static str is a reference valid for the program’s entire execution; a string literal normally has this form because its bytes are embedded in program data. T: 'static means that T contains no non-static borrowed references. An owned String satisfies the bound and can still be dropped moments later.
Thread-spawn and retained-callback APIs often require 'static values because work may outlive the caller’s stack borrows. The honest repair is often to move owned state across the boundary. It is not to manufacture a 'static reference, leak memory, or clone without deciding what the new owner represents.
Retention, not callback syntax, determines the bound
Suppose a validator merely invokes a callback during one call:
fn validate(input: &str, callback: impl Fn(&str) + 'static) {
callback(input);
}
The 'static bound forbids a closure from borrowing request-local context even though validate invokes it and returns. Remove the bound:
fn validate(input: &str, callback: impl Fn(&str)) {
callback(input);
}
If a registry retains callbacks for the process, an owned 'static callback may be the honest contract. If a scoped registry retains them only while an owner lives, expose that relationship. If validation crosses a background boundary, accept an owned command and define cancellation and shutdown. The correct signature follows retention behavior.
A borrowed return becomes an architectural promise
The selector’s &str result avoids an allocation for the call, but it also promises that the chosen name exists in stable, borrowable storage. Replacing String with compressed storage, a database lookup, or a computed label may later require an owned return and break callers. Returning String creates an allocation or transfer contract but decouples representation. Returning Cow<'a, str> preserves a borrowed path while making conditional ownership visible.
Use a borrowed output when identity as a view is meaningful and the owner is a stable part of the API. Do not use it solely because avoiding one allocation sounds efficient. Measure the boundary and account for retained owners, constrained caching, task movement, and representation lock-in.
Where the model goes wrong
- Saying a lifetime is “how long a value lives,” then expecting annotations to extend storage.
- Adding one lifetime to every input, accidentally coupling independent owners.
- Adding
'staticto satisfy a background API without deciding who owns the data. - Leaking a value to manufacture a static reference.
- Returning borrowed internals from a public type whose representation must evolve.
- Treating lexical braces as exact inferred borrow endpoints.
- Converting every borrowed result to owned data without measuring or defining snapshot semantics.
Review the ownership claim in the signature
- Which owner supplies every returned reference?
- Does each lifetime parameter express a necessary relationship?
- Are independent inputs given independent parameters?
- Does a borrowed struct fit the consumer’s storage, task, and cache lifecycle?
- Is
'statica reference lifetime or a bound, and why is it required? - Could owned input satisfy a long-lived boundary more honestly?
- Does the return type freeze internal storage or invite stale snapshots?
- Are allocation and representation trade-offs explicit?
Design exercise: three lifecycles, three APIs
Begin with an event-registration API that requires &'static str event names and F: Fn(&str) + 'static, but whose actual retention behavior has not been decided.
Design a scoped synchronous visitor, an owned process-long registry, and an asynchronous command queue. For each, state who owns names and callbacks, which references may be retained, shutdown behavior, and allocation cost. Add one test that would catch an escaped borrow or an unintended stale snapshot. At least one design must accept request-local context without copying it, and at least one must continue safely after the registration call returns. Memory leaking is not an ownership strategy.
Durable takeaways
- Lifetime parameters express validity and provenance relationships among borrows; they do not control runtime destruction.
- Elision covers common one-source outputs; an output with multiple possible sources needs an explicit relationship.
- Borrowed structs preserve the source’s ownership and trade allocation for lifecycle coupling.
&'static TandT: 'staticmake different claims: the latter can describe an owned value dropped normally.- A good lifetime signature exposes necessary coupling, refuses invented coupling, and matches the API’s actual retention behavior.
Named relationships explain ordinary borrowed inputs and outputs. More demanding APIs must also say who chooses each short relationship—especially when a mutable reference is lent repeatedly or a callback must work without retaining what it sees.
Sources and version notes
- Rust Reference: lifetime parameters
- Rust Reference: lifetime elision
- The Rust Programming Language: validating references with lifetimes
- Standard library:
Cow - Verified with Rust 1.97.0 and Rust 1.85.0. Region diagrams are semantic constraint aids, not depictions of compiler implementation data structures.
Continue reading
Full table of contents