Non-Lexical Lifetimes: How Rust Taught the Borrow Checker Where a Reference Actually Dies
One decision, nine years to ship: stop measuring a borrow’s lifetime by curly braces, start measuring it by last use.
This is the story of one specific Rust compiler limitation, first reported in 2013, and the one redesign that fixed it for good in the 2018 edition. Not a Rust retrospective, just the bug, the reason it existed, and the fix.
The Code That Should Never Have Failed
Here is the shape of the code, reduced to its essentials, that early Rust rejected:
fn main() {
let mut items: Vec<i32> = vec![1, 2, 3];
match items.last() {
Some(value) => println!("last item: {}", value),
None => items.push(4),
}
}
Nothing about this is unsafe. The None arm never touches value, so there is no live reference for items.push(4) to conflict with. Yet for years, this exact pattern, an immutable borrow in one match arm and a mutable one in a branch that never uses it, failed to compile. A version of it shows up in a Rust compiler issue filed in May 2013, traced back to real code in Mozilla’s Servo browser engine, one of the first large codebases written in Rust at all.
One Word Explains the Bug: Lexical
Pre-2018 Rust computed a borrow’s lifetime from the syntax tree. A reference created inside a block was considered alive until that block’s closing brace, full stop, regardless of whether the code after the last real use of that reference ever touched it again. That rule is what “lexical lifetimes” means: the lifetime is a property of where the borrow sits in the source text, not of what the program actually does with it at runtime.
In the example above, items.last() creates a borrow inside the match block. Under lexical lifetimes, that borrow’s scope extended to the end of the entire match, both arms, even though the None arm never mentions it. The compiler wasn’t wrong to worry, it was wrong to worry about the whole block instead of the handful of lines where the reference was genuinely still in play.
| Lexical lifetimes (pre-NLL) | Non-lexical lifetimes | |
|---|---|---|
| What ends a borrow | The closing brace of its enclosing block | Its last actual use, computed per code path |
| Representation checked | The syntax tree (AST) | The control-flow graph in MIR |
| Effect on the example above | Rejected: borrow “alive” through both match arms | Accepted: borrow dead before the None arm runs |
The Fix Needed a New Representation First
Fixing this wasn’t a matter of loosening one rule. Rust’s compiler didn’t yet have a data structure where “which lines can this reference still reach” was even a well-formed question to ask; the AST has no native notion of control flow branching and rejoining. The actual fix, tracked from July 2017 and formalized in RFC 2094 that August, moved borrow checking off the AST entirely and onto MIR, Rust’s mid-level intermediate representation, where the compiler could run a proper liveness analysis over the control-flow graph and compute, per variable, the exact set of program points where a borrow was still reachable.

Shipping It Without Breaking Everyone’s Code
Rust 1.31 shipped December 6, 2018, and with it the 2018 edition enabled non-lexical lifetimes by default, but carefully: in what the compiler team called “migrate mode,” the old lexical checker still ran first, and the new MIR-based checker was only ever allowed to accept programs the old one would have rejected, never to reject something the old one accepted. That ordering made the rollout safe by construction. The 2015 edition got the same treatment starting in early 2019, initially downgrading any newly-surfaced MIR-only errors to warnings so existing code wouldn’t suddenly stop building, then promoting those warnings to hard errors by that August. The old AST-based checker itself was only deleted from the compiler on August 11, 2022, with the release of Rust 1.63.0, nine years after the original bug report.
What the Ecosystem-Wide Test Run Found
Before deleting the old checker for good, the Rust team ran crater, their tool for compiling the entire crates.io ecosystem against a compiler change, to see what would actually break.

Out of 179 total regressions crater turned up, 160 were cases where the compiler had already been warning developers for years that the code would eventually stop compiling. Only 19 were genuinely new breakage nobody had been warned about, out of an entire language’s package ecosystem. That ratio is the real measure of how conservatively this particular redesign was shipped.
What actually changed, if you write Rust
- A match arm, early return, or loop break that doesn’t use a borrowed value no longer needs a workaround to compile
- The mental model shifts from “does this borrow’s enclosing block end soon enough” to “is this borrow still used anywhere reachable from here”
- Code that predates 2022’s Rust 1.63 on the 2015 edition ran on the same NLL checker as everyone else since April 2019, just with a short migrate-mode grace period first
- None of this changes what’s unsafe, only what the compiler can prove is safe without asking you to restructure obviously-correct code
What We Learned
Non-lexical lifetimes fixed one specific, narrow thing: the point in the source code where the compiler considered a borrow to have ended. Moving that computation from the syntax tree to a liveness analysis over MIR’s control-flow graph let the checker see that a reference is dead once its last use has passed, not merely once its enclosing block has closed. The fix took nine years from the first reported case to the deletion of the old checker, shipped in a migrate mode specifically designed so the new checker could only ever accept more code than the old one, and when the training wheels finally came off in 2022, 89 percent of what broke had already been flagged as deprecated for years. It’s a rare example of a compiler team documenting, in public, exactly how conservative a supposedly simple fix actually had to be.


