Skip to content
← All articles

Aliasing XOR mutability: the one rule

The single axiom the whole borrow checker is a corollary of — stated precisely, in terms of places rather than variables, checked statically over every path.

Everything that is about to go wrong for you in this track goes wrong for one reason. Learn the reason now and the next twenty error messages stop being a random sequence of obstacles and become instances of a rule you already know.

Here is the rule.

At any point in the program, for any given place, you may have either any number of shared references (&T) or exactly one exclusive reference (&mut T) — never both, never two of the second kind.

Aliasing (many people can see it) exclusive-or mutability (someone can change it). Pick one. That is the whole of it.

Say it in the compiler’s own words

The Rust Reference is precise about what a borrow does, and its phrasing is worth having verbatim, because the diagnostics are written against it. Creating a shared borrow of a place puts that place — and all places reachable through it — into a state where it

may not be mutated, but it may be read or shared again.

Creating a mutable borrow puts the place into a state where it

may not be accessed in any way.

Read that second one again. Not “may not be mutated”. May not be accessed in any way. While a &mut to something exists, even reading the original is forbidden — and that includes reading it through the owner. This is the sentence beginners have not internalised, and it is why so many borrow errors feel unfair the first time. The owner is not privileged. Lending is not advisory.

💡A variable v: Vec<i64> is mutably borrowed by let r = &mut v;, and r is used later. Which of these are legal in between: v.len(), println!("{:?}", v), v.push(1), r.len()? click to reveal

Only r.len().

All three uses of v are rejected, including the two that are pure reads. v.len() needs &v; println!("{:?}", v) needs &v; v.push(1) needs &mut v. While the mutable loan of v is live, none of those may be created, because a mutable borrow means the place “may not be accessed in any way”.

Access through r is fine — that is what the loan is for. And r.len() works even though len takes &self, because you may always create a shorter, weaker borrow out of a &mut you hold. That is called reborrowing, and item 3.17 is entirely about it.

Places, not variables

Now the part that makes the rule usable rather than merely true.

The rule is not about variables. It is about places — the compiler’s word for “a location a value lives in”: a local, a field of a local, an element of an array, whatever *p points at. A place is what you can write on the left of an =.

Once you think in places, two facts that look contradictory stop being contradictory:

struct Pair { a: Vec<i64>, b: Vec<i64> }

let mut p = Pair { a: vec![1], b: vec![2] };
let x = &mut p.a;      // exclusive loan of the place `p.a`
let y = &p.b;          // shared loan of the place `p.b` — fine, different place
x.push(3);
println!("{}", y.len());

That compiles. p.a and p.b are two disjoint places, and the checker knows it because field names are static. Meanwhile:

let mut v = vec![1, 2, 3];
let x = &mut v[0];
let y = &mut v[1];     // E0499 — rejected

That does not, even though v[0] and v[1] are obviously disjoint to you. They are not obviously disjoint to the compiler: v[i] goes through the IndexMut trait, which takes &mut v — the whole vector — and returns a reference derived from it. As far as the type system can see, both loans are loans of v. The index is a runtime value; the checker has no theory of arithmetic that would let it prove 0 != 1 in general.

This is not a bug and it is not laziness. It is the exact boundary between “the compiler can prove it” and “the compiler cannot”, and the whole expert half of Rust lives on that boundary. When you meet it, the move is not to argue — it is to reach for a standard-library API whose signature carries the proof. split_at_mut returns two slices and its type says they are disjoint. get_disjoint_mut checks at runtime and hands you a Result. iter_mut yields one element at a time and its type says the previous one is gone. Items 3.7, 3.14 and 3.16 are each one of those.

💡&mut s.name and &s.age coexist happily, but &mut v[0] and &mut v[1] do not. Both pairs are disjoint in reality. What exactly is different? click to reveal

Field access is built into the language; indexing is a trait call.

s.name is a place expression the compiler resolves statically. It knows the offset, it knows name and age cannot overlap, and it tracks a loan of s.name as a loan of that specific place — a distinct node in its borrow tree from s.age.

v[0] desugars to *IndexMut::index_mut(&mut v, 0). The thing being borrowed, at the type level, is v — the entire vector — and the result is a reference whose lifetime is tied to that borrow. Two such calls are therefore two overlapping loans of v, regardless of what the index arguments were. The compiler would have to look inside index_mut‘s body and reason about the arithmetic to conclude otherwise, and it deliberately never looks inside another function’s body. Signatures are the contract; bodies are private.

That last sentence is the deep one. The borrow checker is strictly interprocedural through signatures only. It never reads the implementation of the function you are calling. That is what makes it fast, what makes it stable under refactoring, and what makes split_at_mut necessary.

Checked statically, over every path

One more property, and it catches people out constantly: the rule is checked statically, over the control-flow graph, for every path the compiler can see. Not dynamically. Not on the paths that happen to run.

let mut v = vec![1];
let r = &v[0];
if false {
    v.push(2);        // still E0502
}
println!("{r}");

if false is not an escape hatch. Nor is a branch guarded by a condition you know is impossible, nor a path that would have returned earlier for reasons the compiler cannot see. If there exists a path in the CFG from the loan’s creation, through the conflicting action, to a later use of the loan, that is a rejection.

Conversely — and this is the good news, taken up in item 3.5 — the loan ends at its last use, not at the end of its lexical block. A borrow you never touch again is dead, and dead loans conflict with nothing.

💡If the checker is static and rejects paths that never run, how is if false { v.push(2) } different from a program that is genuinely wrong? click to reveal

It is not different, from the compiler’s point of view, and that is the point.

The borrow checker aims at soundness, not completeness. It promises: if it accepts your program, no aliasing violation can occur at runtime. It does not promise: if your program has no aliasing violation at runtime, it will be accepted. There is no such analysis — the property is undecidable in general, so any static checker must reject some correct programs.

So “the compiler is wrong” is almost always “the compiler could not prove it”. The practical consequence is that you should not spend energy proving to yourself that your program is fine. It probably is. The question the compiler is asking is a different one: can you hand me a proof I can check? Items 3.16 and 3.24 return to this.

Why this rule and not some other rule

It buys three things, and they are worth naming because you are about to pay for them.

Memory safety. Iterator invalidation, use-after-free, double-free, and data races all reduce to “someone mutated a thing while someone else held a view of it”. Forbidding that pattern forbids the whole family at once, with no runtime cost. Item 3.12 makes this concrete against C++, Java and Python.

Local reasoning. If you hold a &mut T, you know — not hope, know — that nothing else on Earth can observe or change that value while you hold it. You can therefore reason about the function you are reading without reading any other function. That property is worth more in a large codebase than in a small one, which is why Rust feels like overkill on day one and like relief on month six.

Optimisation. rustc lowers &mut parameters to LLVM noalias, the equivalent of C’s restrict — but applied automatically, everywhere, without you promising anything. The compiler is allowed to cache a value in a register across a call, reorder loads and stores, and collapse redundant branches, because the aliasing rule guarantees no one else can see the difference. This is the answer to “why can’t I just have two &mut, I’ll be careful”: the guarantee has already been spent by codegen. Item 3.23 works through the machine code.

The honest caveat

Rust does not yet have a fully specified aliasing model. That sentence deserves to sit on its own line.

The rule above is normative for safe code, and the borrow checker enforces it. What is not settled is exactly which operations count as invalidating a reference when raw pointers and unsafe are in play — the precise semantics that a miri-style checker must implement, and that optimising compilers are allowed to assume.

The current working model is Tree Borrows (Villani, Jung et al., PLDI 2025), which replaced the earlier Stacked Borrows proposal. Tree Borrows tracks each reference as a node in a tree of derived permissions rather than a stack, which accepts substantially more real-world unsafe code while still rejecting the patterns that break optimisation. It is implemented in Miri and is the model to reason with today — but it is a research model that the language team has not adopted as normative.

None of this affects the safe code you are about to write: in safe Rust the borrow checker’s rule is the rule, full stop. It matters if you ever write unsafe, and it matters for intellectual honesty. Do not repeat the claim that Rust’s aliasing semantics are settled. They are converging, not settled.

What to carry forward

Three sentences.

  1. Shared XOR exclusive, at every program point, for every place.
  2. A mutable borrow means the place may not be accessed at all, including by its owner.
  3. Rejection means “I could not prove it”, which is a different claim from “you are wrong” — and the fix is usually to hand the compiler an API that already contains the proof.

Every error in the rest of this track is one of those three sentences, wearing a number.