Lifetimes Are Not What You Think
The first thing the official book teaches you about lifetimes is wrong.
That is unfair. The first thing the official book teaches you about lifetimes is useful. It is the same way the first thing you are taught about the atom is useful — the planetary model, electrons orbiting like little planets — and then somewhere around your second physics class somebody says actually it’s a probability cloud and the planetary model evaporates and you have to start over.
Lifetimes are like that. The planetary model says: a lifetime is how long a value lives. A reference &'a T lives for as long as 'a lives. Functions take references with lifetimes, and the lifetimes have to “match up.” This is the model that gets you through the borrow checker for normal code. It is enough to write a request handler or a parser combinator. It is not enough to read the error messages we are about to read.
The probability cloud version is this: lifetimes are not durations. They are constraints on relationships between scopes. The compiler doesn’t know how long anything lives at runtime. It can’t. Lifetimes are entirely a compile-time construct, and what they describe is a system of inequalities. 'a: 'b does not mean “'a lives longer than 'b.” It means “'a is at least as long as 'b” — that any reference that satisfies 'a is also one that satisfies 'b. It is a subtyping relation between regions of code.
This sounds pedantic. It is pedantic. It is also the only mental model that makes the next chapter’s error messages comprehensible.
Lifetimes as relations
Consider:
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
}
The planetary model: 'a is the lifetime of x and y and the return value, and they all “live the same amount of time.” Fine. Works. But now:
fn main() {
let result;
let x = String::from("longer string");
{
let y = String::from("short");
result = longest(&x, &y);
}
println!("{}", result);
}
The planetary model says: this should work, because at the point we call longest, both x and y are alive. The compiler says no:
error[E0597]: `y` does not live long enough
--> src/main.rs:6:31
|
5 | let y = String::from("short");
| - binding `y` declared here
6 | result = longest(&x, &y);
| ^^ borrowed value does not live long enough
7 | }
| - `y` dropped here while still borrowed
8 | println!("{}", result);
| ------ borrow later used here
In the relational model, what’s happening is clear. 'a is a single lifetime that has to satisfy all three references — both inputs and the return. The compiler picks the smallest 'a that works. That 'a has to outlive result’s use on line 8. It also has to be a region during which &y is valid. There is no such region. The two constraints are unsatisfiable. There is no 'a that is both at least as long as result’s scope and contained within y’s scope. The compiler is not telling you y “doesn’t live long enough” in some absolute sense; it is telling you that no assignment of regions to 'a makes the inequalities work.
The duration model can’t see this, because in the duration model both x and y are alive at the moment of the call, and that’s the only thing that should matter. The relational model says: it’s not about the call site, it’s about the system of constraints the function signature imposed.
This is the shift. A lifetime annotation is a promise about what relationships the function preserves. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str says: “give me two references that you can find a common lifetime for, and I will return a reference good for that same lifetime.” The function does not store, extend, or shrink anything. It threads the relationship through.
Once you internalize this, signatures stop reading like declarations and start reading like contracts. fn parse<'input>(s: &'input str) -> Token<'input> says: “the token I return cannot outlive the input string I read it from.” fn get<'a, 'b>(map: &'a Map, key: &'b str) -> Option<&'a Value> says: “I’ll return something tied to the map, and the key can come from anywhere — the key’s lifetime doesn’t constrain the result.” The asymmetry of the two parameters is the point.
'static is not “lives forever”
'static is the lifetime that any reference-into-the-static-data-segment satisfies. String literals have type &'static str because the bytes are baked into the binary. That part is well known.
What is less well known: 'static does not require that the data lives forever. It requires that the data could live forever — equivalently, that the type does not borrow from any non-'static region. A String you allocated at runtime is 'static in the sense that satisfies T: 'static, even though you can drop the String and free the memory. The bound T: 'static does not say “lives forever”; it says “owns its data, or borrows from 'static.”
This is why std::thread::spawn requires its closure to be 'static. The thread can outlive the calling scope. If the closure borrowed anything from the calling scope, the borrow could become dangling. So the closure must not borrow anything that is not itself 'static. A String moved into the closure is fine, even though the String will be dropped when the thread ends. An owned String does not borrow.
#![allow(unused)]
fn main() {
fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
}
Read the 'static here as: “this closure does not borrow anything that the calling scope has any business reclaiming.” Not: “this closure runs forever.”
The confusion gets worse when 'static shows up as a bound on a generic parameter, like T: 'static, because then you have a type with a lifetime bound, not a reference. Same rule applies. T: 'static means the type doesn’t borrow non-'static data. Box<dyn Display> is 'static. Box<dyn Display + 'a> is not.
The error message that gets you here is the one that says the parameter type 'T' may not live long enough. The compiler is saying: I cannot prove that T doesn’t borrow from a region that ends. The fix is almost always to add T: 'static (if the type really doesn’t borrow anything ephemeral) or to thread a lifetime parameter through (if it does).
Elision rules and when they fail you
Lifetime elision is the compiler’s promise to fill in lifetimes for you when there is exactly one obvious choice. The rules are:
- Each elided input lifetime gets its own fresh lifetime parameter.
- If there is exactly one input lifetime (elided or not), it is assigned to all elided output lifetimes.
- If there are multiple input lifetimes but one of them is
&selfor&mut self, the lifetime ofselfis assigned to all elided output lifetimes.
That is the whole rule set. Three rules. Memorize them.
These rules cover the vast majority of function signatures. They do not cover:
- Functions returning a reference computed from multiple non-
selfreferences (rule 2 doesn’t apply, rule 3 doesn’t apply, you have to write the lifetime). - Functions where the returned reference’s lifetime should be tied to one specific input but not others (the elision picks
selfif available, which may not be what you want). - Closures (closure lifetime inference is separate from elision and is its own kind of pain — see chapter 3).
- Anything where the desired output lifetime is not equal to any input lifetime (e.g., a function that returns a reference into a
'staticglobal, regardless of input lifetimes — you have to write&'staticexplicitly).
When elision picks the wrong thing, you get a borrow checker error at the call site, not at the function definition. This is one of the most common reasons people stare at a borrow checker error and say “but the function looks fine.” The function is fine. The function’s elided signature is wrong for what the function actually does, and the call site is the first place where the lie shows up.
The fix is to write the lifetimes explicitly. If you find yourself doing this often in a function with &self, ask whether you actually wanted the return tied to self (which elision is giving you for free) or tied to one of the arguments (which you have to spell out).
The mental shift
Stop asking “how long does this live.” Start asking “what relationship is this signature enforcing.” When the borrow checker rejects code, do not look at the offending expression and ask whether the data is alive. Look at the function signatures involved and ask which constraints between which scopes are unsatisfiable.
This shift is necessary because everything in the rest of this book — variance, HRTB, async lifetimes, Pin projections — is reasoning at the level of constraints between regions, not durations of values. If you are still in the duration model, the rest of this book will read as gibberish. If you are in the relational model, it will read as math, which is harder but at least answerable.
A practical exercise: take a function from your codebase whose signature uses lifetime elision. Write out the desugared signature with all lifetimes explicit. Now imagine you are a hostile lawyer trying to break the contract the function signed. What can you pass in that satisfies the signature but violates the function’s actual intent? Most of the time, the answer is “nothing,” and the elision was right. Sometimes the answer is “an argument from a shorter scope than I assumed,” and you have just found a future bug.
What’s coming
The next chapter is variance. It is the prerequisite for understanding why some lifetime substitutions the compiler makes are legal and others aren’t, which is the prerequisite for understanding what the HRTB error in chapter 3 is telling you, which is the prerequisite for chapter 5, which is the prerequisite for Pin. Each chapter assumes the previous one. The relational model from this chapter is the one piece of equipment we will not put down.