Part 2 · The compile-time machine I — borrows & lifetimes
Aliasing XOR mutability
Rust has one rule about references. Everything the borrow checker ever says to you is a consequence of it — including the parts that feel unfair.
What this lesson is trying to break (3)
- The borrow checker prevents crashes by predicting what would go wrong.
- The rule exists only for multithreaded code.
- If the code provably cannot crash, the compiler should accept it.
This is a threshold concept: troublesome, transformative, and irreversible. You should expect to be genuinely stuck here, and being stuck is the mechanism rather than a sign that you are failing. Budget several sessions. Do not move on because you are bored — move on when you can explain it out loud without notes.
Part 1 was about what your program does. Part 2 is about what the compiler will let you write, which is a completely different question, decided by a completely different machine.
Almost all of it comes from one rule.
At any point in the program, for any piece of data, you may have either any number of shared references (
&T) or exactly one exclusive reference (&mut T) — never both at the same time.
That is it. Every borrow-checker error you will ever see is this rule, or a consequence of it. The rest of Part 2 is learning to see it in situations where it is not obvious.
First, the misconception that makes it feel arbitrary
The borrow checker is a very clever bug detector. It analyses my program, works out that something bad would happen, and stops me. So if I can convince myself no crash is possible, the compiler is being unnecessarily strict and I have found a limitation.
The borrow checker does not predict runtime behaviour and does not look for crashes. It enforces a rule about references — a purely structural property — and does so whether or not violating it would actually hurt in your particular case.
Read every borrow error as “you broke the reference rule at line N”, never as “this would crash”. The rule is deliberately simpler than reality: it rejects some programs that are provably fine. That is the price of an analysis a human can predict, and predictability is what you are buying.
This reframing matters practically. If you believe the compiler is predicting crashes, every rejection invites an argument — but look, it can’t reallocate here. If you know it is checking a structural rule, you skip straight to the useful question: which two references overlap, and how do I stop them overlapping?
What goes wrong without the rule
Take the bug in a language you know well. This is real JavaScript:
const xs = [1, 2, 2, 3];
for (const x of xs) {
if (x === 2) xs.splice(xs.indexOf(x), 1);
}
console.log(xs); // [1, 2, 3] ← one of the 2s survived
The iterator holds a position into xs. splice shifts everything down. The
iterator’s index now points past an element that moved, so one 2 is silently
skipped. Nothing crashes; you just get the wrong answer, probably in production,
probably months later.
Every language deals with this somehow. Java throws ConcurrentModificationException
at runtime. Go lets you do it and hope. Python quietly gives you the same class
of wrong answer as JavaScript.
Rust’s equivalent does not compile:
let mut xs = vec![1, 2, 2, 3];
for x in &xs {
if *x == 2 { xs.retain(|v| *v != 2); }
}
error[E0502]: cannot borrow `xs` as mutable because it is also borrowed as immutable
for x in &xs holds a shared borrow for the whole loop. retain needs
&mut xs. Shared and exclusive cannot overlap. Same bug, caught at compile time,
in a language with no runtime checks at all.
Three reasons, not one
Most explanations give the first reason and stop. Give all three and you sound like you have thought about it.
1. Invalidation. A mutation can move or free the data another reference
points at. Vec::push may reallocate, which moves every element to a new
address; any outstanding & into the old buffer would dangle. This is the
memory-safety argument, and it is the one everyone knows.
2. Data races. A data race is exactly two accesses to the same location,
at least one a write, unsynchronised. The borrow rule forbids “shared and
mutable at once” — which is the same shape. This is why Rust gets thread safety
without a second mechanism: it is the same rule, extended across threads by
Send and Sync (4.1). That reuse is genuinely
elegant and worth being able to explain.
3. Optimisation. This is the reason people leave out, and it is the one that survives the objection “but my code is single-threaded”.
When a function takes &mut T, the compiler knows nothing else can see that T.
So it may keep the value in a register across a function call, reorder reads and
writes, and skip re-loading after an opaque call. In C this requires the
restrict keyword and a promise from the programmer; in Rust it is the default
and it is checked. rustc emits LLVM’s noalias on &mut parameters for
exactly this reason.
So the rule is not purely a tax. It buys optimisations that C compilers cannot safely make.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}");
v.push(4);
println!("{:?}", v);
}Careful — this looks like the example from lesson 0.2, but it is not the same.
It compiles. The difference is where the borrow is used.
In 0.2, the println!("{first}") came after the push, so the shared borrow
had to still be alive across it. Here, first is used before the push and
never again — so the borrow is dead by the time push runs, and the two never
overlap.
This is non-lexical lifetimes (NLL), which landed in Rust 2018. Before it, a borrow lasted until the end of its enclosing block and this code was rejected. Now a borrow lasts until its last use.
Getting this distinction is the difference between “the borrow checker hates me” and knowing exactly what to move where. Nine times out of ten, fixing a borrow error means shortening the region where a reference is live — not restructuring your program.
The shape of the rule
Varies: only which kinds of reference are alive at the same moment
| Alive at the same point | Allowed? | Reasoning |
|---|---|---|
&T and &T | Yes, any number | Nobody can write, so nobody can invalidate anything. |
&mut T alone | Yes, exactly one | The writer is the only observer, so there is nothing to invalidate. |
&T and &mut T | No — E0502 | A write could move or free what the reader is looking at. |
&mut T and &mut T | No — E0499 | Two writers is the data-race shape, even on one thread. |
&T and T moved away | No — E0505 | Moving destroys the place the reference points into. |
Five combinations, two allowed. Every error in lesson 2.5 is one of the three bottom rows in disguise.
Note the last row: a move counts as a mutation for this purpose. That is consistent — moving invalidates the source place just as thoroughly as overwriting it does.
A colleague who writes Go says:
“I get why you need this for threads. But my service is single-threaded and I still have to fight the borrow checker. That is pure overhead.”
Write your reply. You must include the optimisation argument, and you must concede the part where they are right — because they partly are, and a reply that concedes nothing will not land.
The honest part
Rust rejects programs that are correct. Not rarely, and not only in exotic cases.
The canonical one is disjoint field borrows through a method:
struct S { a: Vec<u32>, b: Vec<u32> }
impl S {
fn sum_a(&self) -> u32 { self.a.iter().sum() }
fn bad(&mut self) {
let total = self.sum_a(); // borrows all of *self
self.b.push(total); // E0502 — needs &mut all of *self
}
}
Nothing here can possibly go wrong: sum_a only touches a, and we only mutate
b. But borrows are tracked per path, and a method call borrows the whole
self. The compiler cannot see inside sum_a when checking bad.
The fix is to make the disjointness visible — read the fields directly rather than through a method:
let total: u32 = self.a.iter().sum(); // borrows only self.a
self.b.push(total); // borrows only self.b — fine
The compiler does track disjoint fields when it can see them. It just cannot see through a function signature.
This is worth knowing for two reasons. It is the single most common source of “the borrow checker is wrong” complaints, and the answer — make the disjointness visible at the borrow site — is a real technique you will use constantly. And it is honest: a language design has trade-offs, and being able to name Rust’s without defensiveness is more credible than insisting it has none.
What you should now be able to say
- One rule: shared XOR exclusive. Any number of
&T, or exactly one&mut T, never both. - It enforces a structural property of references, not a prediction of crashes.
- Three reasons: invalidation, data races, and
noaliasoptimisation. - Borrows end at their last use (NLL), not at the end of the block — so most fixes are about shortening a region.
- A move counts as a mutation.
- Borrows are tracked per path, and a method call borrows all of
self— which is the main source of false rejections.
Next: 2.2 — &mut does not mean mutable, where
the name turns out to be actively misleading.