Lifetimes — a borrow is a region
Lesson 05 enforced a law on borrows "while they are still needed", and had to admit that it could not say how long that is. This lesson gives a borrow a place to live: a region, the set of program points at which the borrow, or anything made from it, may still be used. The answer to "how long may a borrow live?" is then a containment: a region must fit inside its owner's extent, the stretch of program in which the owner exists. Three error codes — E0597, E0515, E0716 — are that one containment failing for three kinds of owner: a block, a function, a temporary. The proof is entirely static; lifetimes are erased before the program runs and cost nothing. What stays unsettled is that we are still drawing regions as nested blocks, and programs are not nested blocks.
New idea: a lifetime is a region — the set of program points where a borrow may still be used — and a borrow is valid exactly when its region fits inside its owner's extent. The compiler's word for the region is lifetime (
'a in source); this track says region when it means the model and lifetime when it means the syntax.Forces next: A borrow's lifetime is a region of the program, and it must fit inside the owner's. So far we drew regions as nested blocks. The compiler has to compute them on real control flow — branches, early returns, reordered statements. What exactly does it compute?
1 · The owner ends; the borrow does not
Return to the one program of Lesson 05 that the law could not explain:
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("{r}");
}
Read it with the law. x is declared in the inner block, and the closing brace ends it (Lesson 03). The borrow is used through r on the next line. An end while a loan is still needed is the last row of Lesson 05's table, and the compiler files it there: E0505 when the end is a move or a drop(x), E0597 when the end is a closing brace. One conflict, two ways to end a value, two codes.
error[E0597]: `x` does not live long enough
--> src/main.rs:5:13
|
4 | let x = 5;
| - binding `x` declared here
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | println!("{r}");
| - borrow later used here
The same three anchors as Lesson 05 (the borrow, the act, the later use), plus a label for where x was declared, with one change of meaning: the middle anchor is no longer another name acting on the place but the owner ending, marked on the brace that ends it. The message cannot choose the repair. Declaring x in the outer block, copying the value instead of borrowing it, or moving the use inside the inner block are all legitimate, and the carets are where to look: the borrow is made on line 5 for a use that happens after line 6.So the law forbids this program. What it does not supply is the means to know, without running anything, that r is still needed on line 7, and the question has a harder form. A reference can be returned from a function or stored in a struct, and then its later use is in code that the function being checked cannot see. To reason about all of these the compiler needs an object that stands for "where in the program this borrow may still be used".
2 · Three roads to "never outlives"
Before building that object, list what could guarantee that a reference is never used after its referent is gone, and price each against the budget of Lesson 00:
| Road | What makes the guarantee | Price | Who takes it |
|---|---|---|---|
| keep the referent alive | the referent lives as long as anything refers to it: a collector, or a count | a run-time system, or a count on every value | garbage-collected languages; in Go the compiler moves x to the heap when a reference escapes (Go · 03) |
| check at every use | each dereference asks whether the referent is still there: a tag or generation beside each allocation | a test on every dereference, and a failure at run time | debugging tools such as AddressSanitizer, not languages by default |
| prove it before running | a fact about where in the program the reference is used, compared with where the referent exists | nothing at run time; the proof at compile time | Rust |
The C++ row of this table is the empty one: nothing guarantees it, and the program below compiles, runs and looks correct.
int* r;
{
int x = 5;
r = &x; // x ends at the closing brace...
}
std::cout << *r << '\n'; // ...and r is used after it: compiles with no warning at -Wall -Wextra, prints 5
Built with Apple clang on the machine that produced this lesson, it prints 5 at -O0 and at -O2; under AddressSanitizer it stops with stack-use-after-scope. That is the second road, taken by a tool in a test run rather than by the language in every run. The third road is the one the rest of the lesson builds.
r = &x always safe by keeping x alive, which means a heap allocation and a collector behind every borrow of a local. The second would make every dereference pay for a liveness test and turn a wrong program into a crash at run time. Both are good engineering for some languages. Each spends something the promise-plus-budget pair of Lesson 00 does not let Rust spend by default: a run-time system in the first case, a run-time check in the second. The third road pays at compile time instead.3 · Regions and extents
The proof needs two sets of program points. Take a program point to be a place in a function's control flow; for this lesson, a line. The region of a borrow is the set of points at which the borrow, or anything made from it (a copy, a slice, a struct holding it), may still be used: it starts where the borrow is made and runs to the last use along each path. The extent of the owner is the set of points at which the owner exists, from where it is initialized to where it ends. The containment is one sentence:
region of the borrow ⊆ extent of the owner
In the program above, the borrow is made on line 5 and used on line 7, so its region is lines 5–7 (line 6 is in it because the use on line 7 is reachable from there). The owner x exists from line 4 to the brace on line 6. Line 7 is in the region and not in the extent, and that point is the error. The containment also says what an owner can be, and with each kind of owner a different thing ends its extent:
| The owner is… | its extent ends at… | the borrow escapes by… | code |
|---|---|---|---|
| a variable in a block | the closing brace | being used after the brace | E0597 |
| a local of a function | the function's return | being returned: a returned borrow is used in the caller, after the callee's locals are gone | E0515 |
| a temporary | the end of its statement | being used in a later statement | E0716 |
fn pick(text: &String) -> &String {
let copy = text.clone();
© // the borrow leaves the function; copy does not
}
fn main() {
let r = String::from("ada").as_str(); // the String is a temporary: gone at the ;
println!("{r}");
}
The second and third rows need a word of care. A function's local dies at the function's brace, but a borrow of it that is returned has its region extended into the caller by the return itself, so the containment fails without any later line in this function mentioning the borrow. A temporary is a value that no name owns, such as the String made by String::from("ada") above. It ends with its statement, and the repair the compiler suggests is to give it a name, which gives it a block-sized extent:
fn main() {
let owner = String::from("ada"); // a name: the extent is now the block
let r = owner.as_str();
let s = &String::from("ada"); // a borrow written directly on a temporary
println!("{r} {s}"); // is given the block's extent too
}
ada ada
The let s line of that program is a rule of the language, not of the proof: when a temporary is borrowed directly in a let initializer, it is kept until the end of the block. That lifetime extension applies to some expression shapes and not to others (the arguments of an ordinary function call and method receivers are not extended, which is why the as_str() above fails), and the Reference says the exact rules are subject to change. The containment is the part that does not move.
What to try. Start with the owner ends first and move the point from line 4 to line 7. The region of &x is lines 5–7 and the extent of x is lines 4–6, so line 7 is the one point in the first set and not in the second: it is red on the bar, the table says ✗ line 7 outside, and the readout names the brace on line 6. Load used inside the block: the same borrow now has region 5–6, which fits, so the checker accepts it, while the block-shaped model still reports E0597 because it stretches the region to the end of r's scope on line 8. Load a local's borrow is returned: the region is lines 3–4 and ends in a red arrow, since a returned borrow continues into the caller, and copy's extent (lines 2–4) cannot contain that. A temporary fails with an extent of one line, line 2, while the borrow is used on line 3. In a reference into the caller's data the region also continues into the caller, but the owner is not a local of first, so it fits. The three programs after the temporary (a borrow that ends early, a borrow on one branch only, an early return) are the ones §6 is about: the checker accepts each and the block-shaped model refuses it. In the first of them the region is lines 3–4 while r is in scope until line 7, and the orange mark on line 5 is the write that the block-shaped model thinks overlaps it. Every comparison so far set a region against an owner's extent. A borrow that is copied into another reference brings a second region into the comparison, and that is the next step.
4 · Outlives: containment as an order
The containment has a name and a notation. Region 'a outlives region 'b, written 'a: 'b, when 'a contains every point of 'b. Then "the borrow fits inside its owner" reads "the owner's extent outlives the borrow's region", and every assignment between references adds a constraint of the same shape, which the compiler collects and solves (Lesson 07 shows how). One of them is worth deriving, because it fixes a direction. A reference valid over a long region can always be used where one valid over a short region is wanted, since using it for less time is harmless. The reverse would let a borrow be used at a point its region does not contain:
fn shorten<'long, 'short>(x: &'long str) -> &'short str
where
'long: 'short,
{
x // a long borrow stands in for a short one
}
fn lengthen<'long, 'short>(x: &'short str) -> &'long str
where
'long: 'short,
{
x // a short borrow cannot stand in for a long one
}
The second is rejected with "lifetime may not live long enough", a message that has no error code. Its content is the subtyping direction: &'long T is usable wherever &'short T is expected, and never the other way. (The syntax, <'a> after a function's name to declare a lifetime and 'a after the & to use it, is Lesson 08's subject; here it only names the two regions.) The direction is what lets a reborrow (Lesson 05, §5) be a shorter region inside its parent's.
One consequence answers a common belief, that a lifetime annotation makes a value live longer. It only names a region so that the checker can compare it with another. Giving the returned borrow of §3 a name changes nothing about the local it borrows:
fn make<'a>() -> &'a String {
let s = String::from("ada");
&s // naming the region does not extend s
}
If naming a region changes no owner, it should change nothing in the program that runs either. The next section checks that.
5 · Lifetimes are erased
The regions, extents and constraints exist while the compiler checks the program, and only then. Nothing in the generated code carries a lifetime: no field, no tag, no test. The evidence is that the annotations have no effect on the output. These four functions differ only in how they spell their lifetimes (elided, named, two names, one name and one elided):
#[inline(never)]
pub fn head_elided(items: &[u32]) -> &u32 { &items[0] }
#[inline(never)]
pub fn head_named<'a>(items: &'a [u32]) -> &'a u32 { &items[0] }
#[inline(never)]
pub fn head_two<'a, 'b>(items: &'a [u32], _other: &'b [u32]) -> &'a u32 { &items[0] }
#[inline(never)]
pub fn head_one<'a>(items: &'a [u32], _other: &[u32]) -> &'a u32 { &items[0] }
cbz x1, LBB ; is the slice empty? (the bounds check of Lesson 02)
ret ; no: x0 already holds the address, which is the result
LBB:
stp x29, x30, [sp, #-16]!
mov x29, sp
adrp x2, l_anon@PAGE ; the panic location
add x2, x2, l_anon@PAGEOFF
mov x0, #0
bl core::panicking::panic_bounds_check
That listing is each of the four functions, compiled with rustc 1.98.1 -C opt-level=3 for aarch64 on Apple silicon, 2026-09: the four bodies are identical instruction for instruction, once the assembler's local label numbers and constant names are normalized. Every reference is an address (8 bytes on this machine, Lesson 05), and the region it carried is gone. This is the cost side of the third road: the proof is paid for in compile time and in the programmer's attention to signatures, never in the running program. What that leaves open is whether the regions we have been drawing are the regions the compiler computes.
6 · What the block-shaped picture cannot draw
Everything so far drew regions as stretches of nested blocks: a borrow made at one line, used at another, inside the blocks that hold it. That picture gave the right answer for the programs of §1–§3 and it is the natural first model. It is also the shape of the checker Rust shipped until non-lexical lifetimes became stable with the 2018 edition, in Rust 1.31 (2018-12), and it is wrong in the direction of being too cautious. Take the dashed halos in the widget: the block-shaped region of let r = &x runs to the end of the scope of r. Three short programs separate the two models, and in each the checker accepts and the block-shaped model refuses:
fn main() {
let mut x = String::from("ada");
let r = &x;
print!("{r} "); // the last use of r: its region ends here
x.push('!'); // so this write overlaps no borrow
println!("{x}");
}
ada ada!
In the first, the borrow is over before the write, although r is in scope for three more lines; a region is a set of points where the borrow may be used, and r's scope is only where its name exists. The other two programs of the widget change the control flow and leave the verdict the same. In a borrow on one branch only, the borrow is used inside the if and the write is in the else, so the region contains one branch and not the other. In an early return, the borrow is used on a path that then leaves the function, and the write after the if is reached only by paths on which the borrow is already dead. A region is a set of points in a graph of paths, and a block is a shape that set rarely has.
Common mistakes / failure modes
fn make<'a>() -> &'a String still cannot return a borrow of its own local (§4).does not live long enough, I should make it 'static or clone"let initializer borrows it directly. String::from("ada").as_str() does not get that extension (§3).Checkpoint exercise
println! from line 7 up into the inner block, directly under the borrow, and press run edited source. What happens to the region, to the table's last column, and to the block-shaped model's verdict? Answer: line 4 is in the extent of x (lines 4–6) but not in the region (lines 5–7); lines 5 and 6 are in both; line 7 is in the region and not in the extent, and that is the error. After the edit the region is lines 5–6 and the extent of x is lines 4–7, so the table's last column reads ✓ inside and rustc accepts the program. The block-shaped model still reports E0597, because it extends the region to the end of r's scope on line 8. The region shrank because it is defined by uses, and the program has one fewer use after the brace.Where this points next
A borrow is valid when its region lies inside its owner's extent. That answers "how long may a borrow live?": at most as long as its owner, measured in program points rather than wall-clock time, and the answer costs nothing at run time because it is erased. It also exposes the weakness of how we have been drawing it. A region is a set of points in a control-flow graph, and real programs branch, loop and return early, so the nested-block picture both over-rejects and cannot even describe a borrow that is live on one path and dead on another. The compiler has to compute regions on the graph of actual control flow. What exactly does it compute? That is Lesson 07.
E0597), a function whose local is returned (E0515), a temporary that ends with its statement (E0716). Containment is outlives ('a: 'b), and it gives the subtyping direction: a longer borrow may stand where a shorter one is expected, never the reverse. Lifetimes are erased; an annotation names a region and extends nothing. A region is a set of points, not a block, which is why the picture of nested blocks is only the first approximation.Interview prompts
- What is a lifetime, and what does it have to do with scope? (§3 — the set of program points at which a borrow may still be used; the scope is where a name exists, and the region must lie inside the owner's scope.)
- What do
E0597,E0515andE0716have in common? (§3 — the borrow's region is not contained in the owner's extent; they differ in what ends the owner: a block, a function return, the end of a statement.) - Why can't a function return a reference to one of its own locals, when it can return a reference into its argument? (§3 — a returned borrow's region extends into the caller; a local's extent ends at the return, while the argument's referent belongs to the caller and outlives the call.)
- Do lifetimes exist at run time? What does an annotation cost? (§5 — no; they are erased before code generation, and functions differing only in annotations compile to identical assembly.)
- Does adding a lifetime annotation extend how long a value lives? (§4 — no: it names a region for the checker; the example with
make<'a>still fails withE0515.) - What does
'a: 'bmean, and in which direction can a reference be shortened? (§4 — 'a outlives 'b: its region contains the other's; a longer-lived reference may be used where a shorter-lived one is expected, not the reverse.) - How would a garbage-collected language and a C++ program each handle
r = &xwithxin an inner block? (§2 — the collector keepsxalive, at run-time cost; C++ compiles it and the program is undefined, which AddressSanitizer can catch in a test run; Rust refuses at compile time.) - Why is "a borrow lasts until the end of the block" wrong? (§6 — the region covers only the points where the borrow may still be used: it can end before the block does, or exist on one branch and not another.)
Companion reads: C++ · 04 Heap and lifetime (the dangling pointer, unchecked), C++ · 19 Undefined behavior, Go · 03 Pointers, the heap and the GC (the first road of §2), and C++ · 05 RAII (the owner's extent ending at the closing brace, guaranteed by the language).