← index

The Rust Aliasing Model

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:

  1. Read the integer that b points to. Call it x.
  2. Write 0 to the integer that a points to.
  3. Read the integer that b points to again, and add it to x.

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:

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:

error: Undefined Behavior: in-bounds pointer arithmetic failed: attempting to offset pointer by 12 bytes, but got alloc199 which is only 4 bytes from the end of the allocation --> examples/p1_wrong_provenance.rs:10:22 | 10 | let p = unsafe { pa.byte_offset(off) };

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:

error: Undefined Behavior: memory access failed: attempting to access 4 bytes, but got 0x8132c[noalloc] which is a dangling pointer (it has no provenance) --> examples/p2_lost_provenance.rs:9:22 | 9 | let v = unsafe { *q };

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:

error: unsupported operation: integer-to-pointer casts and `ptr::with_exposed_provenance` are not supported with -Zmiri-strict-provenance --> examples/p4_int_to_ptr_cast.rs:7:13 | 7 | let q = addr as *const i32; // integer -> pointer

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:

= help: this indicates a potential bug in the program: it performed an invalid operation, but the Stacked Borrows rules it violated are still experimental

"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:

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:

error: Undefined Behavior: attempting a write access using <553> at alloc199[0x0], but that tag does not exist in the borrow stack for this location --> examples/c3_parent_use_invalidates_child.rs:9:5 | 9 | *c = 2; // then use child -> invalidated | ^^^^^^ this error occurs as part of an access at alloc199[0x0..0x4] | help: <553> was created by a Unique retag at offsets [0x0..0x4] --> examples/c3_parent_use_invalidates_child.rs:7:22 | 7 | let c = unsafe { &mut *b }; | ^^^^^^^

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:

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:

error: Undefined Behavior: write access through <527> at alloc199[0x0] is forbidden --> examples/c11_active_then_foreign_read_then_write.rs:9:5 | 9 | *rmut = 2; // write frozen child -> UB | ^^^^^^^^^ Undefined Behavior occurred here | = help: the accessed tag <527> has state Frozen which forbids this child write access help: the accessed tag <527> was created here, in the initial state Reserved help: the accessed tag <527> later transitioned to Unique due to a child write access

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:

error: Undefined Behavior: write access through <525> at alloc199[0x0] is forbidden --> examples/c12_protector_no_later_use.rs:2:14 | 2 | unsafe { *p = 10; } // foreign write to the protected r | ^^^^^^^ Undefined Behavior occurred here | = help: the accessed tag <525> is foreign to the protected tag <529> (i.e., it is not a child) = help: this foreign write access would cause the protected tag <529> (currently Reserved) to become Disabled = help: protected tags must never be Disabled

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:

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.

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:

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

  1. A reference is a promise to the optimizer, not just a borrow-checker artifact. &mut compiles to noalias; the compiler eliminates memory reads on the strength of it.
  2. 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.
  3. UnsafeCell is the one legal way to mutate through a shared reference. It is what withdraws the noalias/read-only assumption, and every interior-mutability type is built on it.
  4. 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.
  5. Pointer arithmetic preserves provenance. You may only reach the allocation you came from, regardless of what numeric address the math produces.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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