Here are two functions. They do exactly the same arithmetic. One takes Rust references; the other takes raw pointers. The logic is identical, character for character, except for the types:
pub fn refs(a: &mut i32, b: &i32) -> i32 {
let x = *b; // read what b points to
*a = 0; // write zero to what a points to
x + *b // read b again, add to the first read
}
pub unsafe fn raws(a: *mut i32, b: *const i32) -> i32 {
let x = *b;
*a = 0;
x + *b
}
fn main() {
let mut a = 1;
let b = 10;
println!("{}", refs(&mut a, &b)); // 20
println!("{}", unsafe { raws(&mut a, &b) }); // 20
}
Same source, same result at runtime. Yet the compiler turns them
into different machine code. The reference version is
measurably less work for the processor: it reads memory one fewer
time. By the end of this article you will know exactly why, what
invisible promise the reference version is allowed to make that the
pointer version is not, and — most importantly — what
happens when unsafe code makes that promise and then
breaks it.
That last case is the dangerous one. When you break this promise, the compiler does not warn you. Your program does not crash at the broken line. Instead it quietly computes the wrong answer, or corrupts memory, somewhere else entirely. This is undefined behavior, and the rules that define when you have triggered it are the subject of this article: the Rust aliasing model.
A note on what "the model" means. Rust does not yet have a single, finished, officially blessed aliasing model. There are two concrete proposals — Stacked Borrows and its successor Tree Borrows — both implemented in the Miri interpreter, and the language team is still deciding between them. This article covers what exists today and is explicit about which parts are settled and which are in flux. Every example here was run and verified with Rust nightly 1.96.0 (2026-04-07) and the matching Miri; the machine code was produced by the same compiler for 64-bit ARM.
Aliasing, and Why the Compiler Cares
Start with the word itself. Two pointers alias
when they refer to the same piece of memory. If p and
q both hold the address of the same integer, then
writing through p changes what you see when you read
through q. They are two names for one thing.
Most of the time aliasing is harmless. It becomes interesting the moment a compiler tries to make your code fast. Reading from main memory is slow — far slower than working with a value the processor already holds in one of its handful of fast internal slots, called registers. So one of the most basic optimizations a compiler performs is to read a value from memory once, keep it in a register, and reuse it instead of reading it again.
But the compiler can only do that if it is certain the value in memory did not change in between. Look again at the body of those two functions, in plain terms:
- Read the integer that
bpoints to. Call itx. - Write
0to the integer thatapoints to. - Read the integer that
bpoints to again, and add it tox.
The question the compiler must answer is simple: at step 3, does it
need to read memory again, or can it just reuse
x from step 1? The answer depends entirely on one
thing: could the write in step 2 have changed the value that
b points to? That can only happen if a
and b alias — if they are the same address. If
they cannot possibly alias, then *b is unchanged, step
3 can reuse x, and an entire memory read disappears
from the program.
So aliasing is not an academic curiosity. Whether two pointers can alias directly determines how much work your program does. The whole game is giving the compiler permission to assume "these do not alias" — safely.
What &mut Promises the Optimizer
This is exactly what Rust references are for, beyond the borrow checker you already know. Rust's two reference types each carry a guarantee about aliasing:
-
&mut Tis an exclusive reference. For as long as it exists, it is the only way to access that memory. Nothing else — no other reference, no pointer — may read or write it. -
&Tis a shared reference. Many of them may coexist, and — with one exception we will get to — the memory they point at does not change while they are alive. It is read-only.
In safe Rust the borrow checker enforces these guarantees for you; that is what all those "cannot borrow as mutable more than once" errors are about. But the guarantees are not just bookkeeping for the borrow checker. The compiler hands them straight to its optimizer as facts it is allowed to rely on.
We can watch it happen. When the Rust compiler translates your code, it first emits an intermediate form called LLVM IR — a portable, low-level representation that the LLVM backend then turns into machine code. In that IR, function parameters carry attributes: little tags describing what the compiler knows about them. Here are the signatures it generates for our two functions (trimmed to the parts that matter):
; with each fn marked #[no_mangle]:
; rustc -O --crate-type lib --emit=llvm-ir noalias_demo.rs
define i32 @refs(ptr noalias writeonly %a, ptr noalias readonly %b)
define i32 @raws(ptr writeonly %a, ptr readonly %b)
The word to watch is noalias. On refs,
both parameters carry it. noalias is LLVM's
way of saying "for the duration of this function, the memory
reached through this pointer is not reached through any other
pointer." That is precisely the exclusivity promise of
&mut, and the read-only-and-distinct promise of
&, expressed in the optimizer's own vocabulary.
The readonly on b adds that the function
never writes through it, and the writeonly on
a that it never reads through it.
On raws, the noalias is simply gone. Raw
pointers make no promises about aliasing. As far as the optimizer
knows, a and b might be the very same
address.
That single difference changes the machine code. Here is the actual
64-bit ARM assembly the compiler produced for refs
(the // comments are mine):
// with each fn marked #[no_mangle]:
// rustc -O --crate-type lib --emit=asm noalias_demo.rs
_refs:
ldr w8, [x1] // w8 = *b — read b ONCE
str wzr, [x0] // *a = 0 — write zero through a
lsl w0, w8, #1 // return w8 << 1, i.e. w8 * 2
ret
Two registers are in play: x0 holds a,
x1 holds b. The instruction
ldr ("load register") reads from memory;
str ("store register") writes to it. The compiler read
*b exactly once, into w8. Then something
clever: instead of reading *b a second time and adding,
it reasoned that both reads must yield the same value, so
x + *b is just x + x, which is
x * 2, which is a single left-shift by one
(lsl ... #1). The noalias on a
told it the str could not have disturbed
*b, so it folded two reads into one and then into a
shift. The whole function is four instructions.
Now the raw-pointer version, same compiler, same options:
_raws:
ldr w8, [x1] // w8 = *b — first read
str wzr, [x0] // *a = 0
ldr w9, [x1] // w9 = *b — read AGAIN, into a new register
add w0, w9, w8 // return w9 + w8
ret
There are two ldr instructions reading from
[x1]. Without noalias, the compiler had
to assume the str to *a might have changed
*b — because a and b
could be the same address — so it dutifully re-read memory
before adding. Same logic in the source; one extra trip to memory in
the result, on every single call.
This is the payoff of Rust's reference rules, and it is not small.
The promises that &mut and & make
let the optimizer eliminate memory traffic that C, for example,
cannot eliminate without the programmer manually adding
restrict annotations. In safe Rust you get it for free,
and you cannot get it wrong.
The Stakes: Lying to the Optimizer
Here is why this matters so much for unsafe code. The
compiler does not verify the noalias promise.
It trusts it. The promise is attached to a
&mut or & the moment that
reference exists, and the optimizer builds on it unconditionally.
In safe code the borrow checker guarantees the promise is true. But
in unsafe code you can manufacture a reference from a
raw pointer with &mut *ptr, and nothing checks
whether that reference is really exclusive. If you create a
&mut i32 while another pointer to the same integer
is still live and in use, you have told the compiler "nothing else
touches this" while something else demonstrably does. You have
lied.
The compiler then acts on the lie. It might cache a value that has actually changed. It might delete a write it thinks is dead. It might reorder two operations it believes are independent. The result is a program that does not match its own source code — and the symptom usually surfaces far from the unsafe block that caused it, which is what makes this class of bug so brutal to track down. This is the definition of undefined behavior: not "it crashes," but "the compiler's assumptions no longer hold, so all bets are off."
The natural next question is: what counts as a lie? Exactly when is
a reference "exclusive enough"? If I make a raw pointer, then a
&mut, then use the raw pointer once — is that
allowed? That question is what the Stacked Borrows and Tree Borrows
models exist to answer precisely, and we will build them up later.
First we need to deal with an apparent contradiction in the rules
we just stated.
The One Exception: UnsafeCell
We said &T is read-only: the memory behind a shared
reference does not change while the reference is alive. But Rust
programs are full of types that seem to violate this every day.
Cell and RefCell let you mutate data you
only hold by shared reference. A Mutex hands out the
ability to modify shared state across threads. An
Rc updates its reference count every time you clone it,
and you only ever hold an Rc by shared reference. All
of these mutate through &T. How is that not an
instant lie to the optimizer?
The answer is a single type at the bottom of all of them:
std::cell::UnsafeCell<T>. It is the
only way in the entire language to legally mutate
data through a shared reference. Wrapping a value in
UnsafeCell tells the compiler: "for this memory,
withdraw the read-only assumption. A & pointing in
here may observe writes."
Concretely, &UnsafeCell<T> is the one shared
reference that does not get the noalias and
read-only treatment. Watch the attributes disappear. Here is a
function that takes an ordinary &mut i32 alongside
a &UnsafeCell<i32>:
use std::cell::UnsafeCell;
pub fn cell(a: &mut i32, b: &UnsafeCell<i32>) -> i32 {
let x = unsafe { *b.get() };
*a = 0;
x + unsafe { *b.get() }
}
fn main() {
let mut a = 1;
let b = UnsafeCell::new(10);
println!("{}", cell(&mut a, &b)); // 20
}
And its generated signature, next to refs from before:
define i32 @refs(ptr noalias writeonly %a, ptr noalias readonly %b)
define i32 @cell(ptr noalias writeonly %a, ptr readonly %b)
The &mut parameter a keeps its
noalias in both. But on the UnsafeCell
parameter, noalias is gone. The compiler now treats
that pointer as one that may share its memory with others —
which is the whole point, because interior mutability means someone
else might write to it.
Why this particular function still optimizes the same.
If you compile cell above, you may find it produces the
same fast code as refs anyway. That is not a
contradiction: here the other pointer, a, is
still noalias, so the compiler already knows
a cannot be the same memory as b, and that
alone is enough to prove the write to *a cannot disturb
*b. To actually see UnsafeCell change the
output, we need a case where the reference itself is the only thing
standing between "reuse the value" and "read it again."
That case is a function call. Suppose we read *b, then
call some unknown function, then read *b again:
use std::cell::UnsafeCell;
pub fn ref_call(b: &i32, f: fn()) -> i32 {
let x = *b;
f(); // opaque: the compiler cannot see inside
x + *b
}
pub fn cell_call(b: &UnsafeCell<i32>, f: fn()) -> i32 {
let x = unsafe { *b.get() };
f();
x + unsafe { *b.get() }
}
fn main() {
fn noop() {}
let n = 10;
println!("{}", ref_call(&n, noop)); // 20
let c = UnsafeCell::new(10);
println!("{}", cell_call(&c, noop)); // 20
}
The compiler cannot see what f does. For
ref_call, that does not matter: b is a
plain &i32, which is noalias and
read-only for the entire call, so whatever f does, it
cannot legally hold a pointer that writes to *b. The
value is guaranteed stable across the call, and the second read is
eliminated. For cell_call, the UnsafeCell
withdrew exactly that guarantee: f might mutate the
cell through some other alias, so the value must be re-read
afterward. The assembly shows the split cleanly — one load
versus two:
// ref_call — value cached across the call
ldr w19, [x0] // w19 = *b, BEFORE the call (saved register)
... // call f()
lsl w0, w19, #1 // reuse w19: return *b * 2 -> ONE load
// cell_call — value reloaded after the call
ldr w20, [x0] // w20 = *b, before the call
... // call f()
ldr w8, [x19] // *b read AGAIN, after the call
add w0, w8, w20 // return the two reads summed -> TWO loads
ref_call loads *b once, holds it in a
register that survives the call, and reuses it. cell_call
loads it again afterward, because it must. That extra
ldr is the visible cost — and the visible
meaning — of UnsafeCell.
This is why you can never simply cast a &T to a
&mut T and write through it, even if you are certain
no one else is looking. The compiler attached the read-only,
noalias assumption to that &T the
moment it came into being; writing through it is undefined behavior
regardless of whether a second reference ever materializes.
UnsafeCell is not a convenience or a lint-silencer. It
is the load-bearing signal that switches the assumption off, and it
is the only one the language recognizes.
Every interior-mutability type is built on it.
Cell<T>, RefCell<T>,
Mutex<T>, RwLock<T>, and the
atomic types all wrap their contents in an UnsafeCell
at the core, and Rc and Arc use it for
their reference counts. It is
also the reason these types are invariant in their
contents, a consequence explored in the companion article on variance: the same shared-mutation
power that forces noalias off here is what forces
invariance there. Two different lenses on one underlying fact —
that writing through a shared reference is special, and the type
system has to account for it everywhere.
So far the promises have been about which memory a pointer may touch. But there is a second, subtler half to a pointer's identity: not just the address it holds, but where that address came from. Two pointers can hold the same numeric address and still not be interchangeable. That is provenance, and it is where we turn next.
Provenance: A Pointer Is Not Its Address
It is tempting to think a pointer is just a number — the numeric address of a byte in memory. That intuition is wrong, and the gap between "pointer" and "address" is one of the most surprising corners of low-level Rust — and of C and C++ before it; this is not a Rust invention. A pointer carries a hidden second component alongside its address, called its provenance.
Provenance is a tag, invisible in the source, that records where the pointer came from: which allocation it was born out of, and therefore which region of memory it is permitted to access. Two pointers can hold the identical numeric address and yet have different provenance — and the language treats them as different pointers, with different permissions.
Why would the language insist on this? Because of everything in the
previous sections. The optimizer's entire job rests on reasoning
about which pointers can touch which memory. A pointer derived from
one allocation must never be assumed to reach into another; if it
could, none of the noalias reasoning would be sound.
Provenance is how that "derived from" relationship is tracked. The
address tells you where; the provenance tells you what
you are allowed to reach from here.
The cleanest way to feel the difference is to build two pointers
with the same address but different provenance, and watch only one
of them be allowed to work. We take a pointer into one variable,
a, and offset it by exactly the byte distance to a
second variable, b. After the offset, its numeric
address equals b's. But its provenance still says
"I belong to a":
fn main() {
let a = 1i32;
let b = 2i32;
let pa = &a as *const i32;
let pb = &b as *const i32;
// The byte distance from a's address to b's address.
let off = (pb as isize).wrapping_sub(pa as isize);
// Move pa onto b's address. The number now matches pb exactly...
let p = unsafe { pa.byte_offset(off) };
// ...but p still carries a's provenance, and a is a single i32.
let v = unsafe { *p };
println!("{v}");
}
Run it under Miri and it is rejected — not at the read, but one step earlier, at the offset itself. You are not even allowed to form a pointer that strays outside its allocation:
Read that carefully. The offset of 12 bytes is whatever
distance b happened to sit from a on this
run — that number changes from run to run and machine to
machine. What does not change is the rest of the sentence:
pa belongs to an allocation that "is only 4 bytes" long
(a single i32), and an offset of 12 walks off the end
of it. The address you are aiming for might be perfectly valid
memory — it is literally where b lives — but
pa's provenance does not extend there. An address is
not a pointer.
This is the rule provenance enforces: pointer arithmetic preserves the original provenance, and you may only access the allocation that provenance came from. Adding and subtracting from a pointer never lets it "escape" into a neighboring object, no matter what numeric address the arithmetic produces. The compiler relies on exactly this when it reasons that two pointers from two different allocations cannot alias.
Losing Provenance Through an Integer
If a pointer is an address plus provenance, then converting a
pointer to a plain integer must throw the provenance away — an
integer is just a number. This is easy to do by accident, and the
consequences are precise. Rust's "strict provenance" API makes the
two halves explicit. The method addr() extracts only
the numeric address, with no provenance attached. The function
without_provenance() builds a pointer from a bare
address that carries none:
fn main() {
let x = 42i32;
let p = &x as *const i32;
let addr: usize = p.addr(); // just the number, no provenance
let q: *const i32 = std::ptr::without_provenance(addr);
let v = unsafe { *q }; // q has the right address, no provenance
println!("{v}");
}
The address in q is exactly correct — it is the
address of x. But q has no provenance, so
there is no allocation it is allowed to reach, and the read is
undefined behavior:
Miri's phrase for it is dangling — the same word it uses for a pointer to freed memory — because from the language's point of view a pointer with no provenance points nowhere it may legally go, even if its address names live memory.
The fix, when you genuinely need to take an address apart and put it
back together, is to keep a real pointer around and graft the new
address onto its provenance with with_addr():
fn main() {
let x = 42i32;
let p = &x as *const i32;
let addr = p.addr();
let q = p.with_addr(addr); // q = p's provenance + this address
let v = unsafe { *q }; // well-defined
println!("{v}"); // prints 42
}
Here q inherits p's provenance, so it is a
real pointer into x's allocation that happens to carry
a chosen address. Miri accepts it and the program prints
42.
The as Cast, and Two Provenance Models
For most of Rust's history the way to turn a pointer into an integer
and back was the plain as cast:
ptr as usize, then later addr as *const T.
That round-trip is still legal, but what it means turns out
to be subtle enough that there are two different models of it, and
Miri can run under either.
fn main() {
let x = 42i32;
let p = &x as *const i32;
let addr = p as usize; // pointer -> integer
let q = addr as *const i32; // integer -> pointer
let v = unsafe { *q };
println!("{v}");
}
Under Miri's default, the permissive exposed
provenance model, this program is fine and prints
42.
The model says that casting a pointer to an integer
exposes its provenance, recording it in a global pool, and
that a later integer-to-pointer cast may pick up a matching exposed
provenance. It is deliberately forgiving, because mountains of
existing code rely on the round-trip working.
Switch on strict provenance with
-Zmiri-strict-provenance and the same line is rejected:
Notice the wording: this is not "Undefined Behavior," it is
"unsupported operation." That distinction is deliberate and worth
internalizing. Strict provenance is the stricter model: in
it, a bare integer simply cannot carry provenance, so there is no
sound way to manufacture a real pointer from one — Miri
refuses to even try, rather than guess. The permissive model's
exposed-provenance escape hatch is exactly the thing strict
provenance does away with. Code that passes under strict provenance is code
whose pointer origins are all explicit and tracked; that is the
direction the language is steering, through the
addr / with_addr /
without_provenance APIs we used above. For the rare
cases that genuinely need the integer round-trip,
expose_provenance and
with_exposed_provenance remain — named so the
intent is impossible to miss.
Why provenance has to be in the language at all.
It would be simpler if a pointer were just an address. But then the
compiler could never prove that two pointers reaching the same byte
"really" point at different objects, and optimizations like the
noalias reload-elimination from earlier would be unsound
in the presence of any integer-to-pointer trickery. Provenance is
the bookkeeping that lets the optimizer keep its promises. It is the
invisible half of every pointer.
We now have both halves of what a pointer is — an address, and a provenance that says where it may reach. What we still lack is the rule that decides, when several pointers all have valid provenance into the same allocation, which of them is allowed to read or write at any given moment. That is the heart of the aliasing model, and to define it precisely the language needs an operational description of how borrows behave at runtime. That is where Stacked Borrows and Tree Borrows come in.
Why the Rules Need a Running Model
So far the rules have been stated as intuitions: a
&mut must be exclusive, a & is
shared and read-only, do not lie to the optimizer. Intuitions are
enough to write most code, but they are hopeless for deciding the
hard cases. Is it undefined behavior to create a raw pointer from a
&mut, leave it unused, and keep using the
&mut? What if you use the raw pointer once, then go
back to the reference? What if you only read through it? "A
&mut must be exclusive" does not answer any of
these, and the answers matter, because real unsafe code —
inside Vec, inside iterators, inside every data
structure that touches raw pointers — lives precisely in these
corners.
What is needed is a rule precise enough to be executed: given a program, a mechanical procedure that watches every pointer and every memory access and declares, at each step, "this is fine" or "this is undefined behavior." A rule like that is called an operational model. It works by attaching invisible bookkeeping to the running program — extra state that does not exist in the real compiled binary, but that defines what the binary is allowed to do.
This is exactly what Stacked Borrows and Tree Borrows are. Each one gives every pointer a hidden tag and every memory location some hidden state, then specifies how accesses manipulate that state and when they violate it. The Miri interpreter implements this bookkeeping and runs your program against it; that is what we have been doing throughout this article. When Miri reports undefined behavior, it is reporting that one of these models was violated.
Two things to hold onto before we dive in. First, this state is purely a definition device. The real processor does not track tags; the model exists so that "undefined behavior" has a precise meaning, and the optimizer is then free to assume your program never triggers it. Second, none of this is finalized. Miri says so itself, in the help text it prints on every violation:
"Still experimental" is not a hedge; it is the genuine status. There are two models because the language team is still working out which one Rust should adopt. We start with the original, Stacked Borrows, because its successor is best understood as a response to it.
Stacked Borrows
The central image is in the name. Every memory location carries a stack of tags — the "borrow stack" — and every pointer carries one tag identifying its entry on that stack. The tag is provenance made concrete: it is how the model knows which borrow a given pointer represents.
Two operations drive everything. Whenever you create a new reference or pointer, the model performs a retag: it invents a fresh tag for the new pointer and pushes it onto the stack, on top. The most recently created borrow sits highest. And whenever you use a pointer to access memory, the model checks its tag against the stack with one core rule:
- The tag must still be present in the stack. If it is not, the access is undefined behavior.
- Using a tag pops everything above it. Those newer borrows are now considered invalidated — their tags are gone for good.
That popping rule is the whole intuition behind the word "stack." Borrows are meant to nest like function calls: when you create a reference, then a sub-reference from it, then a sub-sub-reference, you are expected to finish with the innermost one first and unwind outward. The moment you reach back to an older pointer, the model concludes that every borrow made after it was a temporary nested thing that should now be over — so it discards them. Go back to a parent, and the children die.
Let us run the rule by hand on the smallest example that breaks. It creates a mutable reference, derives a raw pointer from it, reborrows a second mutable reference from that, then uses the original reference again before the reborrow:
fn main() {
let mut x = 0i32;
let a = &mut x; // mutable reference
let b = a as *mut i32; // raw pointer derived from a
let c = unsafe { &mut *b }; // second mutable reference, derived from b
*a = 1; // use the original again...
*c = 2; // ...then use the reborrow
println!("{x}");
}
Watch the borrow stack for x evolve. A
&mut retag creates a "Unique" tag (it claims
exclusivity); a raw-pointer cast creates a shared read-write tag:
let a = &mut x; stack: [ base, A ] A = Unique, from &mut x
let b = a as *mut i32; stack: [ base, A, B ] B = raw, derived from A
let c = &mut *b; stack: [ base, A, B, C ] C = Unique, derived from B
*a = 1; // write via A — A is in the stack, so pop everything above it
stack: [ base, A ] B and C discarded
*c = 2; // write via C — C is no longer in the stack → UNDEFINED BEHAVIOR
The write through a is legal: its tag A is
in the stack, and using it pops the borrows above —
B and C — off the top. That is the
model deciding that reaching back to the parent ends its children.
The very next line tries to write through c, whose tag
C was just discarded. There is no entry for it, so the
access is undefined behavior. Miri reports exactly this, and even
points back to where the now-invalid tag was born:
The tag <553> is c's. Miri tells you
two things: the write on line 9 used a tag that is no longer in the
stack, and that tag "was created by a Unique retag" on line 7 —
the &mut *b. Translated out of the model's
vocabulary: you reborrowed c, then invalidated it by
using its parent, then tried to use c anyway. That is
the canonical aliasing violation, and it is the same error you get
from two genuinely live &mut references to one
location —
because in the stack model, creating and using the second is
indistinguishable from this.
Stacked Borrows is elegant and it catches real bugs. But the stack discipline is blunt in one specific way, and that bluntness is what motivated a successor. The popping rule fires on any use of an older pointer — including a mere read. Reading through a parent pointer discards the mutable borrows stacked above it, exactly as a write does. Yet a read changes nothing in memory; code that reads through an old pointer and then continues using a newer one can be perfectly sound, and Stacked Borrows rejects it anyway. That over-strictness, on patterns that show up in ordinary unsafe code, is the problem Tree Borrows sets out to fix.
Tree Borrows
Tree Borrows keeps the spirit of Stacked Borrows — tags on
pointers, bookkeeping on locations, undefined behavior when the
rules are broken — but changes two things to cure the
over-strictness we just saw. It is the model the language is most
likely to adopt, and it is what runs under
-Zmiri-tree-borrows.
The first change is in the name. Instead of a flat stack, the borrows form a tree. When you derive a pointer from another — a reborrow, a raw cast — the new pointer becomes a child of the one it came from. Every pointer therefore has a lineage: its parent, its parent's parent, on up to the original allocation at the root. This lineage is what lets the model ask a sharper question on every access.
That question is the second change. For a given pointer, an incoming memory access is a child access if it happens through that pointer itself or any of its descendants, and a foreign access if it happens through anything else — a sibling, an ancestor, an unrelated pointer. And critically, Tree Borrows treats reads and writes differently. A stack just pops; a tree distinguishes "someone read the memory I cover" from "someone wrote it," because those have very different consequences for soundness.
Each pointer, at each location, sits in one of four permission states. A reference moves through them as accesses happen:
-
Reserved — the starting state of a fresh
&mutthat has not been written through yet. It is tolerant: a foreign read does not disturb it. -
Unique — the exclusive, active state,
entered the first time you write through the reference.
This is the state in which the reference enjoys the full
&mutexclusivity guarantee. The original Tree Borrows paper calls it Active; Miri's diagnostics, and this article, say Unique. - Frozen — read-only. A pointer in this state may still be read through, but writing through it is undefined behavior.
- Disabled — dead. Any access through it is undefined behavior. This is the closest analogue to a tag being popped off the Stacked Borrows stack.
For an ordinary &mut or &, these
transitions are the heart of the model, and they fit in a small
table. The columns are the four kinds of access that can hit a
location the pointer covers:
| State | child read | child write | foreign read | foreign write |
|---|---|---|---|---|
| Reserved | Reserved | → Unique | Reserved | → Disabled |
| Unique | Unique | Unique | → Frozen | → Disabled |
| Frozen | Frozen | UB | Frozen | → Disabled |
| Disabled | UB | UB | — | — |
Read the second row, for Unique, against the Stacked Borrows rule from the previous section, and the entire reason Tree Borrows exists jumps out. In Stacked Borrows, any use of an older pointer — read or write — pops the mutable borrows above it. In Tree Borrows, a foreign read of an active reference does not kill it; it merely demotes it to Frozen. The reference survives. You can still read through it afterward. Only a foreign write disables it outright. A read changes no bytes in memory, so a read should not be able to invalidate a reference that is only ever going to read — and in Tree Borrows it does not.
The Reserved row says something similar at the
other end of a reference's life: a brand-new &mut
shrugs off foreign reads entirely, staying perfectly usable. It is
also what lets Rust's two-phase borrows be sound under Tree Borrows:
such a borrow is created early in a method call and must tolerate
reads of the receiver until it is first written — exactly what
the Reserved state permits.
The table sets two things aside for clarity. Interior-mutable memory
is exempt: a pointer into an UnsafeCell makes no
exclusivity claim, so a Reserved pointer there survives
a foreign write instead of being disabled — the same
relaxation that made &UnsafeCell special back in the
optimizer section. Miri bears it out: the foreign-write sequence that
is undefined behavior through a plain &mut comes back
clean once the same memory sits inside an UnsafeCell.
Function arguments are the other omission — they carry an extra
guarantee called a protector, which the protectors section below takes
up.
Where the Two Models Disagree
Now we can put the models side by side on the same programs and watch them part ways. Four small examples are enough. Each builds a mutable reference through a raw pointer, then performs a foreign access through the parent, then uses the child again — varying only whether the child was written first, and whether its final use is a read or a write. Here is what each model decides (verified by running every one under both):
| Example | what happens to the child | Stacked | Tree |
|---|---|---|---|
| parent writes, then child used | Reserved → Disabled | UB | UB |
| foreign read, child stayed Reserved, child reads | Reserved throughout | UB | clean |
| child writes, foreign read, child reads | Unique → Frozen, read OK | UB | clean |
| child writes, foreign read, child writes | Unique → Frozen, write forbidden | UB | UB |
The first and last rows are the agreements, and they are the reassuring part: both models reject the genuinely dangerous programs. The first row is the example from the previous section — a write through the parent, which is a foreign write to the child, disables it under Tree Borrows just as it pops it under Stacked Borrows. The middle two rows are where Tree Borrows accepts code that Stacked Borrows rejects. Look at the third one:
fn main() {
let mut x = 0i32;
let ptr = &mut x as *mut i32;
let rmut = unsafe { &mut *ptr };
*rmut = 1; // child write -> rmut becomes Unique
let _b = unsafe { *ptr }; // foreign read via the parent -> rmut becomes Frozen
let _c = *rmut; // read the frozen child -> fine under Tree Borrows
println!("{} {}", _b, _c);
}
Under Stacked Borrows, the foreign read on the middle line pops
rmut off the stack, and the read on the last line is
undefined behavior. Under Tree Borrows the same foreign read only
freezes rmut, and reading a frozen reference is allowed,
so the program is clean. Nothing unsound is happening: a value was
written, then everyone only reads. Stacked Borrows rejects it purely
because its stack discipline cannot tell a read apart from a write.
The fourth row keeps everything the same but makes the child's final access a write:
fn main() {
let mut x = 0i32;
let ptr = &mut x as *mut i32;
let rmut = unsafe { &mut *ptr };
*rmut = 1; // child write -> rmut becomes Unique
let _b = unsafe { *ptr }; // foreign read -> rmut becomes Frozen
*rmut = 2; // WRITE the frozen child -> undefined behavior
println!("{x}");
}
Now Tree Borrows objects too — and its diagnostic narrates the exact path through the lattice, which is worth reading as a summary of the whole model:
Trace the three lines bottom to top: the tag was born Reserved, became Unique when first written, and by the time of the offending line it had been demoted to Frozen by the foreign read — "which forbids this child write access." That is the freeze doing its job. Tree Borrows is more permissive than Stacked Borrows, but it is not permissive: it still catches every program that actually writes through a reference it has invalidated. It just stops punishing the ones that only read.
Both are still experimental. Notice the table's disagreements are not bug fixes in one direction — they are a change in the definition of undefined behavior. A program in the middle two rows is UB under one model and well-defined under the other. Until the language commits to a model, the only safe reading is the conservative one: code that is clean under both is code you can rely on. We return to this in the guidance and status sections.
Protectors: Function Arguments Are Special
Everything so far has had one shape: invalidating a reference only
bites when you use it again. Create a &mut,
invalidate it through an alias, and nothing is wrong until you touch
the invalidated reference. There is one important place where the
rule is stronger than that — function calls.
When you pass a reference into a function, the callee is entitled to
assume it stays valid for the entire call. That assumption
is what lets the compiler put noalias on
&mut parameters — the guarantee from the very
first section — and rely on it across the whole function body,
even past opaque operations it cannot see through. For that to be
sound, nobody may invalidate the argument behind the callee's back
while the call is running, whether or not the callee ever uses it
again.
Both models enforce this with a protector. For the
duration of a call, the tags of the function's reference arguments
are protected, and invalidating a protected tag is undefined behavior
immediately, at the moment of invalidation — not
deferred to some later use that may never come. Here is the smallest
demonstration. The callee receives a &mut u8 and a
raw pointer aliasing the same byte, writes through the raw pointer,
and then does nothing else — the reference is never touched
again:
fn callee(r: &mut u8, p: *mut u8) {
unsafe { *p = 10; } // foreign write to the protected r
// r is never read or written again
}
fn main() {
let mut x = 0u8;
let p = &mut x as *mut u8;
callee(unsafe { &mut *p }, p);
}
With everything we have learned, you might expect this to be fine:
r is invalidated, but never used, so where is the harm?
The protector is the harm. Tree Borrows reports the undefined
behavior at the write through p, and spells out the
reasoning in full:
The write through p is foreign to r, and
it would push r to Disabled — which
is the one thing a protected tag is not allowed to become.
Stacked Borrows rejects the same program at the same line, in its own
words: "not granting access … because that would remove
[Unique …] which is strongly protected." Note where
the error lands: on line 2, the aliasing write, before
r is ever read. Deferred invalidation has become
immediate undefined behavior, purely because r arrived
as a function argument.
The practical lesson is concrete: passing a &mut
into a function while keeping a raw alias to the same memory, and
then writing through that alias during the call, is undefined
behavior even in code that looks harmless because it never reads the
reference back. It is one of the easier ways to introduce unsoundness
in unsafe code that wraps a self-referential or aliasing data
structure, and it is exactly the case the protector exists to catch.
Protectors are released when the function returns; the guarantee is
scoped to the call.
Reading Miri
Every diagnostic in this article came from one tool, and it is the tool you reach for whenever you write unsafe code: Miri, an interpreter that runs your program on an abstract machine while tracking all the bookkeeping we have described — provenance, borrow tags, permission states, protectors — and stops the instant a rule is broken. It is the closest thing Rust has to a reference checker for undefined behavior. Installing and running it takes three commands:
rustup +nightly component add miri
# run a binary under Miri (Stacked Borrows is the default model)
cargo +nightly miri run
# run your whole test suite under Miri
cargo +nightly miri test
Because the two models disagree on real programs, the single most
important habit is to run both. Tree Borrows is selected through the
MIRIFLAGS environment variable, which is also where the
other useful switches live:
# check under Tree Borrows instead of Stacked Borrows
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri run
# enforce the strict-provenance discipline from the provenance section
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri run
# full backtrace to the operation that triggered the UB
MIRIFLAGS="-Zmiri-backtrace=full" cargo +nightly miri run
The diagnostics reward careful reading, because they are built to reconstruct history, not just point at a line. Every borrow-model error has the same anatomy, which you have now seen several times:
- The first line names what happened — "write access through <tag> … is forbidden," "that tag does not exist in the borrow stack" — and the caret points at the exact access that crossed the line.
-
The
helplines explain why, in the model's own vocabulary: under Tree Borrows, the offending tag's current state and whether the access was child or foreign; under Stacked Borrows, what would have to be removed from the stack. -
The final
helplines trace the tag's biography — "was created here," "later transitioned to Unique," "in the initial state Reserved." Those back-pointers to the originating retag are usually what tells you which reborrow in your code was the real mistake, which is often far from where the access blew up.
Two limitations are worth stating plainly, because they decide how much the green checkmark is worth. First, Miri only checks the code paths your program actually executes. Undefined behavior on a branch you never take, or in a function no test calls, is invisible to it — which is why running it across a thorough test suite, rather than one example, is what makes it trustworthy. Second, it is an interpreter, so it is slow, and it cannot execute calls into native non-Rust code. Within those bounds it is decisive, with one distinction worth keeping. For model-independent undefined behavior — an out-of-bounds access, a use-after-free, a data race — a Miri report means your program definitely has it. For a borrow-model error it means your program violates that model; since the two disagree and neither is final, that is exactly why running both matters. Either way, the history in the diagnostic tells you where it began.
The discipline that ties this together is the one used to verify every claim in this article — a small harness that runs each example under both models and reports any divergence, so that "passes Stacked Borrows" and "passes Tree Borrows" are never assumed, only measured. That habit is the subject of the closing guidance.
Guidance for Writing Unsafe Code
The model is intricate, but the working advice that falls out of it is short. None of these rules require you to memorize the permission lattice; they are the habits that keep you on the safe side of it.
-
Run Miri under both models, over your whole test
suite.
cargo +nightly miri testfor Stacked Borrows and again withMIRIFLAGS="-Zmiri-tree-borrows". Because Miri only sees the code it executes, coverage is what makes the result mean something. This is the single highest-value habit. - Treat "clean under both" as the bar. The two models disagree, and the language has not chosen between them, so a program that is well-defined under only one is betting on the outcome. Code that passes both is portable across whatever Rust settles on.
-
Do not create a reference you will not keep
exclusive. The exclusivity promise of
&mutattaches the moment the reference exists. If you need aliasing, stay on raw pointers; do not bounce a value between a&mutand a raw pointer to the same place. -
Reach for a raw pointer with
&raw, not through a reference. Use&raw const x/&raw mut x(or the olderaddr_of!/addr_of_mut!macros) to take a raw pointer to a place directly. Writing&mut x as *mut _first conjures a real&mut, with all the exclusivity that implies;&raw mut xdoes not. This is exactly why the raw borrow operators were added — originally for fields where forming a reference at all would be undefined behavior. -
Mutate shared data only through
UnsafeCell. Never cast a&Tto&mut Tto write through it, even if you are certain no one else is looking. The read-only assumption was baked into that&Twhen it was born;UnsafeCellis the only sanctioned way to withdraw it. -
Keep provenance intact. Do not launder pointers
through
usize. Useaddr(),with_addr(), andmap_addr()to manipulate addresses while carrying provenance, and check your crate under-Zmiri-strict-provenance. Reserveexpose_provenance/with_exposed_provenancefor the rare cases that genuinely need the integer round-trip. -
Be careful with
&mutarguments and aliases. Passing a&mutinto a function protects it for the whole call; writing to the same memory through an alias during that call is undefined behavior even if the reference is never read again.
Status: What Is Settled and What Is Not
It would be a mistake to leave this article thinking the rules above are carved in stone. They are not, and being precise about which parts are stable is itself part of understanding the model.
Neither Stacked Borrows nor Tree Borrows is the official, normative aliasing model of Rust. The Rust Reference does not bless either one; both are research-grade proposals, implemented in Miri, that the operational-semantics working group is using to converge on a future specification. Every Miri diagnostic in this article says as much in its own help text: the rules it cites are "still experimental." Tree Borrows is the more likely future — it was designed as Stacked Borrows' successor, it accepts more sound real-world code, and the cases where it is more permissive are the cases that matter in practice — but "likely" is not "final," and details continue to move.
Despite that, a solid core is settled, and it is the part worth internalizing because it will survive whatever model wins:
-
&mut Tis exclusive and&Tis shared and read-only, withUnsafeCellas the sole exception. This is guaranteed by the language, independent of any borrow model. -
Pointers carry provenance; you may only access the allocation a
pointer's provenance came from, and stepping outside it with
offsetoraddis undefined behavior the moment you form the pointer — the one-past-the-end pointer excepted, andwrapping_offsetexcluded, since it computes out-of-range addresses without that penalty. The strict-provenance APIs are stabilized. -
Casting
&Tto&mut Tand writing through it is undefined behavior. Full stop.
What is genuinely in flux is the fine structure: the exact corner cases where Stacked Borrows and Tree Borrows disagree, like the read-after-foreign-read patterns from the divergence section. If your code's correctness depends on one of those corners, it depends on an unsettled question, and the conservative response — pass both models — is the right one. Treat this article as a snapshot: it was written against Rust nightly 1.96.0 (2026-04-07), and both the diagnostic wording and the sharp edges of the models will keep shifting under it.
Key Takeaways
- A reference is a promise to the optimizer, not just a borrow-checker artifact.
&mutcompiles tonoalias; the compiler eliminates memory reads on the strength of it. - Breaking that promise in unsafe code is undefined behavior, and it is silent. No crash at the offending line — just wrong results or corruption, somewhere else.
UnsafeCellis the one legal way to mutate through a shared reference. It is what withdraws thenoalias/read-only assumption, and every interior-mutability type is built on it.- A pointer is an address plus provenance. Two pointers with the same address but different provenance are different pointers; an address is not a pointer.
- Pointer arithmetic preserves provenance. You may only reach the allocation you came from, regardless of what numeric address the math produces.
- The rules need a running model to be precise. Stacked Borrows and Tree Borrows define undefined behavior operationally, with hidden tags and state that Miri checks as your program runs.
- Stacked Borrows is a stack of tags; using an older pointer pops the newer ones. It is simple but blunt — even a read invalidates mutable borrows above it.
- Tree Borrows is a tree with a four-state lattice. Reserved → Unique → Frozen → Disabled; a foreign read freezes rather than kills, so it accepts sound code Stacked Borrows rejects.
- Function arguments are protected for the call. Invalidating a passed reference through an alias is undefined behavior immediately, even if the reference is never used again.
- Neither model is final; "clean under both, checked by Miri" is the strongest guarantee available today. The high-level rules are settled; the corner cases are not.
Further Reading
- The Rustonomicon: Aliasing — the official prose introduction, and the
UnsafeCelldocumentation for the exception - Stacked Borrows: An Aliasing Model for Rust — Ralf Jung et al., POPL 2020, the original model and its soundness argument
- Tree Borrows — Neven Villani's site, the successor model in full, with an interactive explorer; see also Ralf Jung's announcement post
- Strict Provenance — the standard library's account of provenance and the
addr/with_addr/expose_provenanceAPIs - Miri — the interpreter used to verify every example here; the README documents the flags
- The Rust Reference: Behavior Considered Undefined — the settled list of what is always undefined behavior, model-independent