all_lessons/Rust/06 · Lifetimes — a borrow is a regionlesson 7 / 23

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.

The thesis, here
A reference must never outlive what it points to. There are three ways to guarantee that: keep the referent alive, check at every use, or prove it before the program runs. Only the last fits the budget, and the proof needs one idea — a borrow has a region, its owner has an extent, and the region must lie inside the extent.
Linear position
Forced by: The law says many readers or one writer, never both — but it constrains borrows that overlap in time, and we have not said how long a borrow lasts. Nothing yet stops a reference from outliving the thing it points to. How long may a borrow live?
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?
The plan
Six moves. (1) Show that the law already forbids a borrow outliving its owner, and that what it lacks is where. (2) List the roads to "never outlives" and take the one the budget allows. (3) Define region and extent, state the containment, meet the three errors as three kinds of owner, and run the rule on real programs. (4) Read containment as an order, outlives, and find the direction in which a reference may be shortened. (5) Check that none of this exists at run time. (6) Find what the block-shaped picture cannot draw.

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.

Reading the error
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:

RoadWhat makes the guaranteePriceWho takes it
keep the referent alivethe referent lives as long as anything refers to it: a collector, or a counta run-time system, or a count on every valuegarbage-collected languages; in Go the compiler moves x to the heap when a reference escapes (Go · 03)
check at every useeach dereference asks whether the referent is still there: a tag or generation beside each allocationa test on every dereference, and a failure at run timedebugging tools such as AddressSanitizer, not languages by default
prove it before runninga fact about where in the program the reference is used, compared with where the referent existsnothing at run time; the proof at compile timeRust

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.

Roads not taken · a collector, and a check at every use
The first road would make 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 blockthe closing bracebeing used after the braceE0597
a local of a functionthe function's returnbeing returned: a returned borrow is used in the caller, after the callee's locals are goneE0515
a temporarythe end of its statementbeing used in a later statementE0716
fn pick(text: &String) -> &String {
    let copy = text.clone();
    &copy                            // 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.

Regions against scopes
Pick a program, then slide the program point down its lines. Thick bars are borrows and cover the region the checker computed; thin bars are where variables exist (dark for an owner, light for a reference). The table computes the containment for each borrow: the lines of its region that fall outside its owner's extent turn red. A dashed halo shows what a block-shaped region would be: from the borrow to the end of the scope of the variable holding it. The last two cards give the verdict of the block-shaped model and of the checker.
borrows
—
points outside an owner's extent
—
block-shaped model
—
would rustc accept it?
—
Edit the program
Show the core JS
var set = {}, pt = F.pts[L.rv], vis = {}, q = L.node >= 0 && F.reach[L.node] ? F.nodes[L.node].succ.slice() : [];
if (L.line) set[L.line] = 1;
while (q.length) {
  var p = q.shift();
  if (vis[p] || !pt[p] || !F.reach[p]) continue;
  vis[p] = 1; if (F.nodes[p].line) set[F.nodes[p].line] = 1;
  F.nodes[p].succ.forEach(function (s) { q.push(s); });
}
var region = Object.keys(set).map(Number).sort(function (a, b) { return a - b; });
var regs = F.reachableRegions(L.rv), toCaller = false;
F.regions.forEach(function (rg) { if (rg.kind === 'univ' && regs.has(rg.id)) toCaller = true; });
var root = F.locals[L.place.l], behind = L.place.p.some(function (s) { return s.k === 'd'; }), kind, from, to;
if (behind) { kind = root.param ? 'caller' : 'reborrow'; from = 0; to = Infinity; }
else if (root.temp) { kind = 'temp'; from = root.line || L.line; to = F.endLine[root.id] !== undefined ? F.endLine[root.id] : from; }
else if (root.param) { kind = 'param'; from = root.line || 1; to = F.endLine[root.id] !== undefined ? F.endLine[root.id] : fnEnd; }
else { kind = 'local'; from = root.line; to = F.endLine[root.id] !== undefined ? F.endLine[root.id] : fnEnd; }
var outside = region.filter(function (ln) { return ln > to; });
var fits = outside.length === 0 && !(toCaller && to !== Infinity);

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.

What this lesson did not do
It defined the region as "the points where the borrow may still be used" and then drew it by eye. It did not say how the compiler finds those points on a program with branches, loops and early exits, what "derived from the borrow" means precisely, or how a borrow that is stored in another variable extends the region of the original. Those are the computation, and the computation is the next lesson's subject. It also did not say how a function that returns a borrow tells its callers which input the result is tied to; that needs signatures (Lesson 08).

Common mistakes / failure modes

"a lifetime annotation makes the value live longer"
It names a region for the checker to compare; it never extends an owner. fn make<'a>() -> &'a String still cannot return a borrow of its own local (§4).
"a borrow lasts until the end of the block"
It lasts over the points where it may still be used, which can end lines before the block does, or cover one branch and not the other (§6, the widget's last three programs).
"lifetimes exist at run time, and can grow and shrink"
They are static: the checker uses them and they are erased. Functions that differ only in their lifetime annotations compile to identical code (§5).
"the scope of a variable and the lifetime of a borrow are the same thing"
The scope is where a name exists; the region is where a borrow may still be used. The region must fit inside the owner's scope, and is usually smaller than the reference's own (§3, §6).
"if it says does not live long enough, I should make it 'static or clone"
The message is the containment failing. The repairs are to lengthen the owner, shorten the use, or own the value; adding names to signatures rarely helps (§1, §4).
"a temporary lives as long as I use it"
A temporary ends with its statement unless a let initializer borrows it directly. String::from("ada").as_str() does not get that extension (§3).

Checkpoint exercise

Try it
In the widget, load the owner ends first and move the program point to each line. For each point, say whether it is in the borrow's region and whether it is in the owner's extent, and name the line that makes the program an error. Then edit the source: move the 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.

Takeaway
A reference must never outlive its referent, and the three ways to guarantee that are to keep the referent alive (a collector), to check at every use (a run-time cost), or to prove it before running (Rust). The proof is a containment: the borrow's region — the points where it may still be used — must lie inside its owner's extent. It fails in three ways, by the kind of owner: a block that ends before the use (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

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).