Lifetimes
When and why they appear, elision rules, 'static, struct lifetimes.
Lifetimes
Every reference in Rust has a lifetime — a region of code over which the reference is valid. The compiler tracks lifetimes implicitly almost everywhere; you only have to write them down when the compiler can't infer the relationship between input and output references.
The mental model: lifetimes are not durations. They're scopes. 'a doesn't mean "5 milliseconds" — it means "the region of code where this reference is guaranteed to point at live data".
Why they exist
Without lifetimes, this would be possible:
fn dangle() -> &String { // ❌
let s = String::from("hi");
&s // returning a reference to a local
}
s is dropped when dangle returns. The reference would be dangling. The compiler refuses to compile it: "missing lifetime specifier — this function's return type contains a borrowed value, but there is no value for it to be borrowed from".
The function's return type needs a lifetime to express what it borrows from — and there's nothing valid to choose.
Elision — the lifetimes you don't write
Most function signatures don't need explicit lifetimes. Three elision rules quietly fill them in:
-
Each input reference gets its own lifetime.
fn one(x: &str) { /* ... */ } // becomes: fn one<'a>(x: &'a str) -
If there's exactly one input lifetime, all output lifetimes equal it.
fn first(s: &str) -> &str { &s[0..1] } // becomes: fn first<'a>(s: &'a str) -> &'a str -
If there's
&self, all output lifetimes equalself's lifetime.impl Parser { fn next_token(&self) -> &str { /* ... */ } // becomes: fn next_token<'a>(&'a self) -> &'a str }
If none of these rules give the compiler an answer, you must annotate.
Where elision fails
Multiple input references and a returned reference, no &self:
fn longer(a: &str, b: &str) -> &str { // ❌
if a.len() > b.len() { a } else { b }
}
Which input does the output borrow from? a or b? The compiler doesn't know — both are possible at runtime. You answer:
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
Both inputs get the same lifetime 'a. The output is &'a str. The compiler now understands: the returned reference is valid for the intersection of the input lifetimes — i.e. as long as both a and b are still alive.
Lifetime annotations are constraints, not durations
'a doesn't pick a length. It says "these references all share some common lifetime". The compiler picks the actual length to make the constraints solvable. You're describing relationships between scopes, not measuring time.
Try to read <'a> as "for some lifetime 'a chosen by the caller…". The function works for any lifetime that satisfies its constraints.
Struct lifetimes
When a struct holds a reference, the struct itself needs a lifetime parameter:
struct Excerpt<'a> {
part: &'a str,
}
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let e = Excerpt { part: first_sentence };
// e cannot outlive `novel`, because e.part borrows from it
The struct's lifetime parameter makes the dependency explicit: an Excerpt<'a> is "an excerpt of something that lives for 'a". When novel goes out of scope, any Excerpt<'a> borrowing from it must already be dead.
This is why most idiomatic Rust returns owned Strings rather than &strs — the moment you return a reference, every caller has to plumb the lifetime through. Owned values just work.
'static — the all-program lifetime
let s: &'static str = "hello, world";
'static means "valid for the entire program". String literals get &'static str because their bytes live in the binary itself.
You'll also see 'static in trait bounds:
fn spawn<F>(f: F)
where
F: FnOnce() + Send + 'static,
{
std::thread::spawn(f);
}
Reading: "F can be sent to another thread, doesn't borrow anything that could go out of scope before the thread finishes." 'static here doesn't mean the closure runs forever — it means it doesn't capture short-lived borrows.
Lifetime bounds — 'a: 'b
Rare, but you'll see this on advanced APIs:
fn relate<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'b: 'a reads "'b outlives 'a". The constraint says: y's lifetime must contain x's. With that promise, returning a &'a str that came from y is safe — y is at least as long as x, so a reference valid for y is certainly valid for x.
Multiple input lifetimes
When references are independent, give them distinct lifetimes:
fn print_pair<'a, 'b>(a: &'a str, b: &'b str) {
println!("{} {}", a, b);
}
This is what elision rule 1 produces. Each input gets its own. Use distinct names when the input lifetimes don't need to relate to each other or to outputs.
NLL — non-lexical lifetimes
The borrow checker uses non-lexical lifetimes (NLL): a borrow ends at its last use, not at the end of the lexical scope. This is what makes a lot of "obviously fine" code compile:
let mut v = vec![1, 2, 3];
let r = &v[0]; // borrow starts
println!("{}", r); // last use of r — borrow ENDS here
v.push(4); // ✅ OK — no live borrow at this point
Without NLL the borrow would extend to the closing } and the push would fail. NLL has been on by default since 2018.
When you genuinely need to write lifetimes
In practice, three places:
- Functions that return references derived from multiple inputs. Elision can't pick which input.
- Structs that hold borrowed data. The struct must declare the lifetime of what it borrows.
- Trait methods returning references when default elision picks the wrong source. Rare.
Beyond these, write owned types where you can. Lifetime-laden APIs are powerful but viral — once a &'a str enters your type, every type that holds one needs 'a too.
Reading lifetime errors
The compiler emits unusually good errors here. Read them carefully:
error[E0106]: missing lifetime specifier
--> src/main.rs:3:31
|
3 | fn longer(a: &str, b: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
|
3 | fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
| ++++ ++ ++ ++
The hint at the bottom is usually the answer. Apply it, run cargo check again, iterate.
Lifetimes are the single feature that takes longest to feel natural. Once they click — usually around the time you write your second struct that borrows — they become invisible again.
Tools in the wild
4 tools- clirustcfree tier
The compiler error messages for lifetime issues are unusually good — read them word-by-word.
- clicargo checkfree tier
Type-check + borrow-check without code generation. The lifetime feedback loop.
- cliPoloniusfree tier
Next-generation borrow checker — accepts more lifetime patterns; opt-in via nightly.
- serviceRust Playgroundfree tier
Paste annotated lifetimes, hit Build, get the borrow checker's verdict in milliseconds.