Part 2 · The compile-time machine I — borrows & lifetimes
Lifetimes are regions of code, not durations of time
The single most damaging misconception in Rust. A lifetime is not how long a value lives — it is a set of places in your program, and it is inferred, not chosen.
What this lesson is trying to break (5)
- A lifetime is how long a value exists at runtime.
- `&'a T` means "this reference lasts for duration 'a".
- Annotating a lifetime tells the compiler to keep something alive longer.
- `'a` in a signature is a specific lifetime the function author picks.
- Lifetimes cost something at runtime.
This is a threshold concept: troublesome, transformative, and irreversible. You should expect to be genuinely stuck here, and being stuck is the mechanism rather than a sign that you are failing. Budget several sessions. Do not move on because you are bored — move on when you can explain it out loud without notes.
This is the hardest lesson on the site. It is also the one that changes the most, because almost everyone carries a wrong model here for a long time — including people who write Rust professionally and get by on pattern-matching against compiler errors.
You are going to be stuck. Budget more than one sitting.
The misconception
A lifetime is a duration. &'a str means “a reference that lives for the
period of time called 'a”. Longer lifetimes are bigger durations, 'static
is the longest one — the whole program — and writing 'a on something tells
the compiler how long to keep it alive.
Nothing about a lifetime is temporal, and annotations never keep anything
alive. If durations were the model, fn f<'a>(x: &'a str) would have to
mean some specific span — but the same function works for callers whose data
lives for a microsecond and callers whose data lives forever, with no change.
A duration cannot do that.
A lifetime is a region: a set of points in your program’s control-flow graph over which a reference must stay valid. Not a span of time — a set of places in the code. The compiler computes it from where the reference is actually used. You never choose one; you only describe how several of them relate.
That is not a metaphor or a simplification for teaching. It is the literal definition used by the compiler. The RFC that introduced the current borrow checker has a section titled “Lifetimes are sets of program points”.
What the compiler actually computes
Take this function and label every point:
fn main() {
let mut v = vec![1, 2, 3]; // 1
let r = &v[0]; // 2 ← reference created
println!("{r}"); // 3 ← last use of r
v.push(4); // 4
println!("{:?}", v); // 5
}
The borrow checker asks one question: at which points must r be valid?
It is created at 2 and last used at 3. So its region is the set {2, 3}. That is
the lifetime. Not “about two nanoseconds” — the set containing points 2 and 3.
point │ what happens │ is the borrow of v live here?
──────┼───────────────────────┼──────────────────────────────
1 │ v created │
2 │ r = &v[0] │ ● region starts
3 │ println!("{r}") │ ● last use — region ends
4 │ v.push(4) │ needs &mut v — no conflict
5 │ println!("{:?}", v) │
Then it checks whether anything conflicts within that set. v.push(4) needs
&mut v at point 4, and 4 is not in {2, 3}, so there is no overlap and the
program is accepted.
Move the println!("{r}") to after the push and the region becomes
{2, 3, 4, 5}. Now point 4 is inside it, the exclusive borrow overlaps the
shared one, and you get E0502.
Nothing about that reasoning involves time. It is set membership over a graph.
This is what “non-lexical lifetimes” means, by the way. Before NLL, regions were forced to be whole lexical blocks; now they are computed from actual use. The name describes what was removed, which is why it is such an unhelpful name.
// A
fn a() {
let mut v = vec![1];
let r = &v[0];
v.push(2);
println!("{r}");
}
// B
fn b() {
let mut v = vec![1];
let r = &v[0];
println!("{r}");
v.push(2);
}
// C
fn c() {
let mut v = vec![1];
{
let r = &v[0];
println!("{r}");
}
v.push(2);
}B and C compile. A does not.
In A, r is used after the push, so its region includes the push point.
Conflict — E0502.
In B, r’s last use is before the push, so the region ends first. No overlap.
C also compiles, but here is the point: the braces are doing nothing. C behaves identically to B. Before NLL the braces were load-bearing — you genuinely had to write them to end a borrow early. Since Rust 2018 they are noise.
If you see explicit scoping blocks like C in a codebase, you are usually looking at either pre-2018 code, or a habit someone never dropped. Recognising that is a small but real reading skill — it tells you something about the age of the code.
Annotations describe relationships, not durations
Now the part that trips up people who have got everything so far.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
The usual reading is: “both arguments must have the same lifetime, and the result has that lifetime too.” That is wrong in a way that matters.
'a is a variable, universally quantified. The signature says: for any
region 'a the caller cares to pick, if both inputs are valid across 'a, the
output is valid across 'a too. The author is not naming a duration. They are
stating a relationship between inputs and output.
The caller instantiates it. And because references are covariant in their lifetime — a reference valid over a big region can be used where one valid over a smaller region is wanted — you can pass two references with completely different scopes. Both get shortened to a common region.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("a long string");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
}
println!("{result}");
}No — E0597: s2 does not live long enough.
Work it out with regions. result is used at the println!, so whatever
longest returns must be valid there. The return type is &'a str, so 'a must
include the println! point.
But 'a must also be a region over which both inputs are valid, and s2 is
dropped at the closing brace. There is no region that contains the println!
and over which s2 is valid. No valid instantiation exists, so the call is
rejected.
Now the important part: you cannot fix this with annotations. People try
'static, or two separate lifetimes <'a, 'b>, or adding 'a: 'b. None of it
works, and it cannot work, because the problem is not how the relationship is
described — the problem is that s2’s data is gone by the time you use the
result.
The only real fixes change the program:
- move
println!inside the block (shrink the region), or - move
s2’s declaration outward (make the value live longer), or - return an owned
String(stop borrowing).
This is why “just add a lifetime annotation” is bad advice, and why it feels like Rust is punishing you for not knowing the magic word. There is no magic word. The annotation was never the mechanism.
Lifetimes cost nothing
Worth stating flatly, because it comes up in discussions and people get it wrong in both directions.
Lifetimes are erased before code generation. There is no runtime
representation of 'a. No check happens while your program runs. Two functions
that differ only in lifetimes compile to byte-identical machine code.
Contrast this with the other half of the type system: generics (3.3) do affect codegen, because monomorphisation generates a copy per type. Lifetimes never do. Same syntax position in the angle brackets, completely different consequences — and being precise about that difference is a good marker of understanding.
Two relations you will read constantly
Varies: what is on the left of the colon
| Written | Read as | Means |
|---|---|---|
'a: 'b | "'a outlives 'b" | Region 'a contains region 'b. Anywhere 'b is valid, 'a is too. |
T: 'a | "T outlives 'a" | Every reference inside T is valid across all of 'a, so a T may be held there. |
T: 'static | "T contains no borrowed data" | Either owned data, or references to things valid for the whole program. Not "lives forever". |
The colon is a subset relation on sets of program points. Reading it as 'outlives' is idiomatic, but the set reading is the one that makes errors decodable.
That third row is worth dwelling on, because 'static is the most misread thing
in the language and it has two different meanings depending on where it
appears:
&'static T— a reference valid for the entire program. String literals, and things you deliberately leaked.T: 'static— a bound, meaningThas no borrowed data with a shorter region.Stringsatisfies it.i32satisfies it.&'a strdoes not, unless'ais'static.
When tokio::spawn requires F: Future + Send + 'static, it is the second
meaning: your future must not borrow anything from the calling stack frame,
because the task may outlive it. It is emphatically not “your future must live
for the whole program”.
Getting this wrong is extremely common, and getting it right is a reliable signal in conversation. We come back to it in 2.4.
Why the error messages use time words
One loose end that causes real confusion.
If lifetimes are regions, why does the compiler say “s2 does not live long
enough”? That is duration language, and it teaches the wrong model to every
beginner who reads it.
The honest answer: the error messages describe the consequence in human terms rather than the mechanism. “Does not live long enough” means “there is no valid region assignment”, but that sentence would help nobody. The messages are aimed at getting you unstuck, not at teaching the theory.
So: trust the error’s location, and translate its wording yourself.
Explain to a colleague, without using the words “time”, “long”, “duration” or
“live”, what fn first<'a>(items: &'a [String]) -> &'a str guarantees.
Then answer: what would change if the signature were
fn first<'a, 'b>(items: &'a [String]) -> &'b str? Is that even implementable?
(Second part is hard. Attempt it before deciding.)
What you should now be able to say
- A lifetime is a region — a set of program points where a reference must be valid. It is inferred from use.
'ain a signature is a universally quantified variable the caller instantiates. Annotations state relationships, never durations.- Adding annotations cannot make a value live longer. The fixes are: shrink the region, extend the value’s scope, or stop borrowing.
- Lifetimes are erased — zero runtime cost, unlike generics.
'a: 'bis set containment.T: 'staticmeans no borrowed data, not lives forever.- The compiler’s time-flavoured wording describes the symptom, not the mechanism.
Next: 2.4 — Reading lifetime annotations,
which is the practical half: elision, '_, and the annotations you will actually
meet in real signatures.