JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Lifetimes and the Borrow Checker, Formally

You met ownership and borrowing on the on-ramp; now we make them precise. A lifetime is a region of code, the borrow checker is a static proof that no reference outlives its data and no aliasing-with-mutation ever happens — and once you see it as a proof, the rejections start making sense.

From a slogan to a rule the compiler can check

On the Rust on-ramp you learned the slogans: every value has one owner, you can hand out a borrow instead of moving, and a borrow is either one shared reference or one exclusive one. Those slogans are true, but they are not yet a rule a machine can verify. To go deep you need the precise version, and it rests on one definition: a lifetime is not a duration in seconds — it is a region of your code, a set of program points over which a particular reference is required to stay valid. Read that again, because nearly every confusing borrow-checker error dissolves once you stop thinking "how long in time" and start thinking "over which lines."

With that, the whole borrow checker becomes one job: prove, at compile time, that two properties hold for every reference in the program. First, no reference is ever used outside the lifetime of the data it points at — a reference must never outlive its referent, which is exactly the dangling-pointer bug C lets you write freely. Second, the borrows in scope at any moment never violate aliasing XOR mutability: you may have many shared `&T` readers, or exactly one exclusive `&mut T` writer, but never both at once. The checker is a static analysis that either finds such a proof or rejects your program. It is not a runtime guard and it adds no instructions; it is a referee that runs before your code ever exists as machine code.

Why outliving is the whole game

Let us see the bug the checker is built to stop, in C and then in Rust. In C you can return the address of a local: the function returns, its stack frame is reclaimed, and the caller is left holding a pointer into memory that no longer belongs to anyone. That is a dangling pointer, and dereferencing it is undefined behavior — it may print garbage at -O0 and be deleted entirely by the optimizer at -O2. Nothing in C checks this for you; the discipline lives in your head. Rust catches the exact same shape at compile time by refusing to let a reference escape the region in which its referent is alive.

Picture the smallest version. A Rust function `fn dangle() -> &i32` declares a local `let x = 7` and tries to return `&x`. But `x` is owned by `dangle`, so it is dropped at the closing brace — the returned reference would point into a frame that no longer exists. The compiler refuses with "this function's return type contains a borrowed value, but there is no value for it to be borrowed from." It reasoned about the region in which `x` is alive (just the function body) and saw that the reference is required to be valid in a strictly larger region (the caller), so the containment fails.

Notice what the checker is really doing in its head: it assigns each value a region where it is alive (the owner's scope), assigns each reference a region where it must stay valid (everywhere it is later used), and demands the second region fit inside the first. The borrow's region must be a subset of the referent's region — that single containment is the formal heart of "a reference may not outlive its data." This is the same hazard as a C use-after-free, but turned from a runtime crash you hope to never hit into a compile error you cannot ignore.

Naming the regions: lifetime parameters and elision

When a function takes references and returns one, the compiler has to know which input the output borrows from, so it can demand the right containment. You spell that out with a lifetime parameter — a name like `'a` that stands for "some region the caller picks." In `fn longest<'a>(x: &'a str, y: &'a str) -> &'a str`, the `'a` says: the returned reference is valid for whatever region is common to both inputs, and no longer. The lifetime is not a thing you compute or allocate; it is a constraint variable the checker solves for, exactly like a type parameter, only it ranges over regions of code instead of types.

If every borrowing function forced you to write `'a` by hand, Rust would be unbearable. It does not, thanks to lifetime elision — a small set of mechanical rules the compiler applies so you can usually omit the annotations. The rules are blunt on purpose: each elided input reference gets its own fresh lifetime; if there is exactly one input lifetime, the output borrows from it; and if there is a `&self`, the output borrows from `self`. That is why `fn first(s: &str) -> &str` needs no annotation at all. Elision is pure sugar — it never changes what is legal, it only lets you skip writing the obvious case. When the rules cannot guess (two input references, no `self`), the compiler stops and asks you to name the region yourself, which is precisely the case `longest` hits.

Aliasing XOR mutability: the deeper invariant

Outliving is only half the proof. The other half, and the part that makes Rust special among safe languages, is aliasing XOR mutability: at any program point, a piece of data may have many shared readers (`&T`) or exactly one exclusive writer (`&mut T`), but never a writer alongside any other reference. "XOR" is the whole idea — aliasing and mutation are allowed individually, forbidden together. This is what kills the iterator-invalidation family of bugs: you cannot hold a `&mut` to a vector (to push into it) while also holding a `&` into one of its elements, because the push might reallocate the buffer and leave your element reference dangling. C and C++ let you do exactly that; Rust's checker proves you never can.

There is a beautiful payoff hiding here, and it is the real reason the rule is so strict. If the compiler has proven that a `&mut T` is the only reference reaching that data, then it knows nothing else can observe or change the value behind it. That is the same no-aliasing guarantee C only gets by sprinkling the `restrict` keyword and hoping the programmer told the truth — and in Rust it is guaranteed by construction, for free, everywhere. So aliasing XOR mutability is not bureaucratic pedantry; it is simultaneously a safety property (no data races, no iterator invalidation) and an optimization license (the compiler may keep values in registers and reorder reads freely). The rule that feels like it is fighting you is also the rule earning you C-level speed.

Non-lexical lifetimes: the checker grew up

Early Rust tied a borrow's region to the textual scope it was declared in — its lexical block — which rejected a lot of obviously-fine code. The modern checker uses non-lexical lifetimes (NLL): a borrow lives only from where it is created to its last actual use, not all the way to the closing brace. This is why the example below now compiles. The shared read of `v` ends the moment its last use does, freeing up the slot so the exclusive `&mut` push can begin — the two borrows do not actually overlap in the program, even though the lexical scopes of their variables do.

let mut v = vec![10, 20, 30];
let first = &v[0];        // a shared borrow of v begins
println!("{first}");      // ... its LAST use is right here
                          // NLL: the shared borrow ENDS here, not at the brace
v.push(40);               // now the exclusive &mut borrow is allowed -- OK

// Reorder so the shared borrow is still live across the push:
let first = &v[0];
v.push(40);               // error: cannot borrow `v` as mutable
println!("{first}");      //   because it is also borrowed as immutable here
NLL ends a borrow at its last use, not its closing brace. The first block compiles; reordering so the shared read survives across the push restores the aliasing-XOR-mutability error.

NLL matters because it moves the checker closer to rejecting only the programs that are genuinely unsound, and accepting the ones a careful human can see are fine. It is honest, though, to admit the checker is still conservative: it is a static approximation, so there remain correct programs it cannot prove safe and will reject — self-referential structs and certain graph shapes are the classic ones. That is a real cost, not a flaw you imagined. When you hit it, the move is not to fight the borrow checker but to change the data structure (use indices instead of references) or to opt into a checked runtime tool, which is exactly what the next guides on interior mutability and unsafe are about.

Variance, and a fair summary

One last piece makes the model complete, and it is the subtle one: variance — the rule for when a reference with a longer lifetime may be used where a shorter one is expected. Intuitively this is fine for reading: a `&'long T` can stand in for a `&'short T`, because anything valid over a long region is certainly valid over a contained shorter one (we call this covariant in the lifetime). The catch is `&mut T`, which is invariant: you may not substitute lifetimes in it at all, because mutation through it could store a too-short reference into too-long storage and reopen the dangling-pointer hole the checker just closed. You will rarely write variance down, but it is the reason a `&mut` is pickier than a `&`, and knowing the word turns a baffling error message into a solvable one.

So here is the whole machine, fairly stated. The borrow checker is a compile-time proof of two invariants — no reference outlives its referent, and aliasing XOR mutability — expressed over lifetimes understood as regions of code, refined by NLL to track last-use rather than scope, and made sound at the edges by variance. When it accepts your program, you get C's speed with no class of memory bugs — no use-after-free, no double-free, no data race — and you pay zero runtime cost for the guarantee. That is the honest deal, and it is a genuinely good one.