What the Borrow Checker Actually Checks: Permissions Over Time
The borrow checker is not a list of syntax rules — it is a permission analysis over time. For every reference the compiler computes a live range (the region where the reference may still be used) and then verifies, for every moment in the program, that the permissions in effect are legal. The legal combinations at any point are:
| Data access in effect | Meaning |
|---|---|
any number of &T |
shared read access |
exactly one &mut T |
exclusive read-write access |
| no references | the owner may use/move the value freely |
Two rules sit underneath. Alias xor mutation: while shared borrows exist, no mutation of the referent; while a mutable borrow exists, no other borrows and no direct use of the owner. No use after free: a moved-out value has no owner and cannot be touched; a borrow never outlives its referent. Everything the checker rejects is one of these rules, applied to a specific time window.
The model becomes concrete on this example:
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // shared borrow; first's live range starts here
println!("{}", first); // last use of first — its range can end HERE (NLL)
v.push(4); // mutation — legal only because first is dead by now
}
Before 2018-era NLL this code was rejected — first's lexical scope covered v.push. Today the checker ends the borrow at its last use, and the mutation fits after it. The mental discipline the rules demand: stop asking "where is the reference declared?" and start asking "until where might it be used?". Every surprising rejection or acceptance is answered by that question.
A second lens that helps in interviews: think of &T as a read permission slip and &mut T as a write permission slip handed to a borrower. The owner carries the base permission; creating a borrow moves (part of) it out and getting it back requires the borrower's slip to expire. v.push(4) above needed the base write permission, which was only available once first's read slip expired. And the checker's guarantee, once it accepts the program: no dangling references, no aliasing mutation, no data races — in safe code. unsafe blocks suspend individual checks (raw pointers bypass the analysis), which is precisely why unsafe is where those invariants become your job to uphold.
Trace the permissions line by line on a small program — this is the exercise that turns the model mechanical:
fn main() {
let mut v = vec![1, 2, 3]; // 1 owner: RW
let r = &v[0]; // 2 owner: -- r: R
println!("{}", r); // 3 r last used → r: dead, owner: RW again
v.push(4); // 4 legal: owner holds RW
let m = &mut v; // 5 owner: -- m: RW (exclusive)
m.push(5); // 6 write through m
drop(m); // 7 m's region ends → owner: RW
println!("{:?}", v); // 8 legal
}
Two things to notice. The owner's permission fluctuates: it is suspended while a borrow is live and restored when the borrow dies — nothing is copied or moved to make that happen, it is bookkeeping in the analysis. And every transition happens at a specific program point, so a rejected program always has a specific line to change. If you can produce this table for a snippet in your head, you can predict the compiler's verdict before you compile.
The permission lens also explains why moving while borrowed is its own error (E0505) rather than being folded into the aliasing rules: a move is an owner-side action, and the owner's permission was suspended. You will meet it when a function consumes a value somebody still reads:
let orders = vec![Order::new()];
let first = &orders[0];
consume(orders); // E0505: cannot move out of `orders` because it is borrowed
println!("{:?}", first);
Same story as every other conflict: an interval (the borrow) overlaps an action (the move), and one of them has to move — literally or in source order.
Non-Lexical Lifetimes: Borrows End at Last Use, Not at Braces
NLL (non-lexical lifetimes, the 2018 edition's game-changer) replaced the old lexical rule — "a borrow lasts until its declaring block ends" — with a data-flow analysis: a borrow's lifetime is the region from creation to its last possible use. This made huge amounts of previously-rejected code legal without weakening any guarantee.
The canonical demonstration:
fn main() {
let mut list = vec![String::from("a"), String::from("b")];
let borrowed = &list[0]; // shared borrow of list[0]
println!("{}", borrowed); // last use — NLL ends the borrow here
list.push(String::from("c")); // compiles TODAY
} // (pre-NLL: E0502 — borrow still 'live' to `}`)
The mechanism is precise: NLL computes, for each reference, the set of points where it may still be read later. After the final such point, the reference is dead — using list mutably then is legal even though the variable borrowed is technically "in scope" until the closing brace. Scope (syntax) and live range (analysis) are now different things, and the live range is what matters.
NLL also enabled control-flow-sensitive reasoning:
fn pick(cond: bool, v: &mut Vec<i32>, idx: usize) {
let r = if cond { &mut v[idx] } else { &mut v[0] };
*r += 1; // one borrow, whichever branch ran
}
…while still rejecting genuine conflicts across branches. Where NLL stopped is worth knowing for the next layer: it reasons per-reference about uses, but some conflicts need reasoning about why a borrow exists — the famous "Polonius" project aims to make more of those cases pass (e.g. returning a field-borrow from a struct based on a condition). Until Polonius ships, the workaround patterns you will meet in Refactoring Borrow Checker Errors are how you satisfy the current analysis.
Reading errors in the NLL era: the two spans now point at regions, not just lines. A typical E0502:
error[E0502]: cannot borrow `config` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let name = &config.name;
| ----------- immutable borrow later used here
5 | config.refresh();
| ^^^^^^ mutable borrow
6 | println!("{}", name);
| ---- immutable borrow used here
The note "immutable borrow later used here" is the tell: the shared borrow's live range extends past the mutation because name is read afterward. Kill or move the later use (copy the value out, reorder the calls, narrow the borrow into a block) and the same code compiles. The spans literally show you the overlapping interval — read them as a timeline, not as a list of complaints.
Two practical consequences of NLL are worth internalizing, because they decide whether a loop or a pattern compiles.
First, borrows do not automatically persist across loop iterations — each iteration's borrows start and end independently:
let mut v = vec![1, 2, 3];
for i in 0..v.len() {
let item = &v[i]; // region begins here
println!("{}", item); // and ends here
} // no borrow survives the closing brace
v.push(4); // legal — nothing from the loop is live
The borrow inside the iteration dies at its last use within that iteration, so the loop as a whole leaves no residue. Trouble starts only when a borrow escapes an iteration — stored in a struct, returned, or captured by something that outlives it — and then the analysis correctly asks it to live across all of them.
Second, the checker runs on MIR (the compiler's lowered control-flow graph), not on your surface syntax — and that has edition-visible effects. In if let Some(x) = v.pop() { ... } else { ... }, the temporary produced by pop() used to be kept alive for the entire if let, including the else branch, because the scrutinee's temporary scope was the whole statement. Edition 2024 changed that: scrutinee temporaries are dropped before else runs, so code in the else that wants to mutate v no longer fights a dead-but-live temporary borrow. The general habit stands either way — when a temporary is fighting you, bind it to a name first (let popped = v.pop();), and the temporary's lifetime becomes a line of code you control instead of a rule you look up.
The Conflict Catalog: E0499, E0502, E0505, E0596 and What Each Enforces
By this point every borrow-checker error you meet is one of a small set. Learning to name the rule from the code is the fastest way to fixing it without trial and error.
| Code | Message shape | Rule enforced |
|---|---|---|
| E0499 | cannot borrow x as mutable more than once |
one &mut at a time |
| E0502 | cannot borrow x as mutable because it is also borrowed as immutable |
alias-xor-mutation (readers exclude writers) |
| E0505 | cannot move out of x because it is borrowed |
moves need the base permission — no move under outstanding borrows |
| E0596 | cannot borrow x as mutable, as it is not declared as mutable |
binding must opt into mutability |
| E0382 | use of moved value | one-owner / no-use-after-move |
Each names an interval overlap, and the fix is always to shrink, reorder, or restructure the intervals:
// E0502: the reader's range covers the write
let peak = v.iter().max(); // borrow alive: peak used below
v.push(9); // ERROR
println!("{:?}", peak); // <- extends the borrow past the push
// Fixed: end the reader's range first
let peak = v.iter().max().copied(); // copied() detaches: peak is i32, no borrow
v.push(9);
println!("{:?}", peak);
That copied() fix — extract the data you actually need, ending the borrow — is pattern #1 of the next topic. Two more shapes to recognize on sight:
// E0499: two live mutable borrows
let a = &mut v;
let b = &mut v; // ERROR — unless the compiler sees a used BEFORE b's creation
use(a);
// E0596: forgot the binding keyword
let data = vec![1];
data.push(2); // ERROR: cannot borrow as mutable, not declared mut
let mut data = vec![1]; // the entire fix
One conflict belongs to a different family: two-phase borrows. Code like v.push(v.len()) — a mutable borrow whose argument needs a shared borrow of the same value — compiles, because the checker treats the mutable borrow of v.push as having a reservation phase that coexists with the argument's read, upgrading to exclusive access only when the call executes. Knowing it exists explains why some apparently-aliased calls pass, and saves you from "the borrow checker is arbitrary" despair: it is a precise analysis, and where it is not yet precise enough, the error names exactly which interval to change.
The move-under-borrow error deserves its own example, because it appears when a value is consumed while a reader still points into it:
struct Config { name: String, retries: u32 }
fn ship(cfg: Config) { /* consumes */ }
fn main() {
let cfg = Config { name: String::from("prod"), retries: 3 };
let preview = &cfg.name; // shared borrow, later used below
ship(cfg); // error[E0505]: cannot move out of `cfg`
// because it is borrowed
println!("{}", preview);
}
E0505 is the base-permission rule from the model: a move is the owner acting, and the owner's permission is suspended while preview is live. The three fixes are the standard ones in order of preference — extract what you need (let preview = cfg.name.clone(); or preview.len() computed before the move), reorder so the borrow's last use precedes the move, or pass &cfg to ship if it does not genuinely need ownership. Cloning is last because it is the only one that costs anything at run time.
Every conflict in this topic resolves to one of four moves, and having them as a checklist removes the guesswork:
| Intervention | Kills conflicts by |
|---|---|
Extract data (.copied(), .len(), .clone()) |
removing the borrow entirely — the value you needed outlives on its own |
| Reorder uses | shrinking the borrow's region past the conflicting action |
| Narrow into a block | ending the borrow at a closing brace you chose |
Restructure (split_at_mut, take the value, Rc) |
making the intervals provably disjoint or removing the alias |
Work down the list; it is ordered from cheapest to most invasive. And when the checker rejects something that looks obviously fine, remember what it is telling you: the analysis is conservative about why a borrow exists but exact about when it is used — so the first three interventions almost always produce a program that compiles, is no less clear, and has the same behavior at run time.
Quiz
1. What does the borrow checker fundamentally do — and what are the legal access states at any moment?
2. Under non-lexical lifetimes, when does a borrow end?
3. An E0502 error says the immutable borrow is 'later used here', pointing past a v.push() line. What does that tell you, and what is the standard fix?
4. Why does `v.push(v.len())` compile even though both borrow v — one mutably, one shared?
Flashcards
Question
The borrow checker's model in one sentence, and the three legal access states.
Click to reveal answer
Answer
It is a static data-flow analysis computing each reference's live range and enforcing permissions within it. Legal at any moment: any number of shared &T borrows, OR exactly one &mut T, OR no borrows (owner uses/moves freely) — plus never use a moved value, never let a borrow outlive its referent.
Question
Non-lexical lifetimes (NLL): what changed, and what does it not solve?
Click to reveal answer
Answer
Borrows end at their last POSSIBLE USE (data flow), not the closing brace — unblocking mutation after a borrow's final read, and enabling branch-merged borrow reasoning. It does not reason about why borrows exist (e.g. returning a field borrow from &self conditionally) — those cases await Polonius and need restructuring patterns today.
Question
Name the five classic borrow/ownership error codes and the rule behind each.
Click to reveal answer
Answer
E0499 — two live &mut (single mutator). E0502 — &mut while & may still be used (alias-xor-mutation). E0505 — move out while borrowed (moves need base permission). E0596 — borrow as mut a binding not declared mut. E0382 — use of moved value (one owner). All interval overlaps; fix by shrinking/reordering intervals.
Question
How do you read a two-span borrow error?
Click to reveal answer
Answer
As a timeline: one span marks where the conflicting borrow STARTED, the other where it is still LIVE ('later used here'). The overlap window between them is the problem; the fix lives between the spans — end the earlier borrow sooner (extract data, reorder), narrow it into a block, or restructure so the regions don't intersect.
Question
What is a two-phase borrow, and why does v.push(v.len()) pass the checker?
Click to reveal answer
Answer
A mutable borrow that begins as a shared-compatible 'reservation' while receiver arguments are evaluated, then upgrades to exclusive at the call. v.len()'s shared read happens in the reservation phase and ends before activation, so mutation and read never overlap — a more precise analysis, not an exemption.
Revision Notes
Key Takeaways
- 1. The checker enforces permissions (many &, one &mut, or owner-free) over each reference's data-flow live range — all static, zero runtime cost
- 2. NLL ended borrows at last use instead of the closing brace: scope and live range are different, and every acceptance/rejection is answered by the live-range question
- 3. Borrow errors are interval overlaps: the two spans show the conflicting window, and the fix always shrinks, reorders, or restructures it
- 4. The five codes cover nearly everything: E0499/E0502 (aliasing), E0505 (move under borrow), E0596 (forgot mut), E0382 (use after move)
- 5. Two-phase borrows explain the surprising successes (v.push(v.len())) — the analysis is precise about when mutation actually happens
Interview Tips
- • "What does the borrow checker actually do?" — compute reference live ranges via data flow and enforce alias-xor-mutation plus single-mutator within them; framing it as permission analysis over time beats reciting surface rules
- • "What did non-lexical lifetimes change?" — borrows end at last use, not block end; give the push-after-last-read example and note what still needs Polonius (borrow-returning-from-&self cases) to show the frontier
- • "Explain this E0502 to me" — walk the spans as a timeline: the 'later used here' note names the use extending the shared region past the mutation; the fix extracts the data or reorders so regions stop overlapping
- • "Does the borrow checker slow down my program?" — no: all analysis is at compile time; references carry no runtime metadata, which is the zero-cost-safety story; unsafe is where the invariants become the programmer's job
Cheat Sheet
The Borrow Checker Rules
Model: static data-flow permission analysis per reference live range.
Any moment must be one of:
- many
&T(shared reads), OR - exactly one
&mut T(exclusive RW), OR - none (owner uses/moves freely)
Plus: no use-after-move (E0382) · no borrow outliving its referent (E0106/E0597).
NLL: borrows end at LAST USE, not }. Scope ≠ live range.
let first = &v[0];
println!("{}", first); // borrow's last use
v.push(4); // legal — the borrow already ended
Error catalog:
| Code | Shape | Rule |
|---|---|---|
| E0499 | &mut twice |
single mutator |
| E0502 | &mut vs live & |
alias-xor-mutation |
| E0505 | move while borrowed | moves need base permission |
| E0596 | &mut of non-mut binding |
binding must opt in |
| E0382 | use after move | one owner |
Reading errors: spans = timeline; 'later used here' names the use extending the region. Fix between the spans: extract data (.copied()), reorder, narrow into a block.
Two-phase borrows: v.push(v.len()) — reservation (shared-compatible) → activation (exclusive at call).