Unsafe code must follow Rust's aliasing rules: roughly, a &mut T is the
only way to reach its target while it is in use, and a &T does not see
the target change (except inside UnsafeCell).
Aliasing, mutable references and unsafe code
describes those rules informally. This article covers two precise proposals
for them, Stacked Borrows and Tree Borrows, and Miri, the tool
that checks programs against them.
Miri is an interpreter for MIR, the mid-level intermediate representation that rustc produces. It runs your program on Rust's abstract machine (the model the language rules are written for) instead of on the hardware, and it stops with an error when an operation breaks one of the rules it checks. An aliasing model is the part of those rules that decides which pointers may still be used after other pointers to the same memory have been used.
Why a precise model is needed
"&mut T is exclusive" is easy to state but not precise enough to check.
A checker needs a rule that says, for each read or write in a program,
whether it is allowed and which other pointers it invalidates. The rule must
be strict enough to justify the optimizations the compiler wants to make,
and permissive enough to accept code that real libraries, including the
standard library, rely on.
The Rust Reference states that Rust does not yet have a defined memory model. (Atomic operations are specified separately: Rust's atomics currently follow the C++20 rules.) The aliasing rules for references are one of the unsettled parts. Stacked Borrows and Tree Borrows are research proposals for them; neither has been adopted as the official rule.
Stacked Borrows
Stacked Borrows was introduced by Ralf Jung, Hoang-Hai Dang, Jeehoon Kang and Derek Dreyer (POPL 2020). It is the model Miri uses by default.
In simplified form:
- Each memory location carries a stack of permission entries.
- Creating a reference, or a raw pointer from a reference, gives the new pointer a tag and pushes an entry for that tag onto the stack.
- Writing through a pointer removes every entry above that pointer's
entry. Reading invalidates only the exclusive (
&mut) entries above it. - Using a pointer whose entry is no longer on the stack is undefined behavior.
- An entry created from a shared reference grants read access only (except
for data inside
UnsafeCell), so writing through a pointer derived from&Tis also undefined behavior.
Here a raw pointer p is derived from r, then r is written before p:
fn main() {
let mut x = 0;
let r = &mut x;
let p = r as *mut i32;
*r = 1; // removes p's entry from the stack
unsafe { *p = 2 }; // Stacked Borrows: undefined behavior
}
Running it with cargo +nightly miri run (Miri nightly of 2026-10-05)
reports:
error: Undefined Behavior: attempting a write access using <551> at alloc196[0x0],
but that tag does not exist in the borrow stack for this location
The numbers in angle brackets and after alloc identify the tag and the
allocation; they vary between runs and versions. Miri also points to the
line that created the tag and the line that invalidated it.
Swapping the last two accesses is accepted:
fn main() {
let mut x = 0;
let r = &mut x;
let p = r as *mut i32;
unsafe { *p = 2 }; // p's entry is still on the stack
*r = 1; // removes p's entry, but p is not used again
assert_eq!(x, 1);
}
So is reading through r before writing through p, because a read does
not remove a raw pointer's entry:
fn main() {
let mut x = 0;
let r = &mut x;
let p = r as *mut i32;
let _seen = *r; // a read leaves p's entry in place
unsafe { *p = 2 };
assert_eq!(x, 2);
}
Check your understanding
The first two programs differ only in the order of the last two lines. Why does order matter when both pointers point at the same i32?
Show answer
Hide answer
Because the model tracks where each pointer came from, not just its
address. p was derived from r, so Stacked Borrows treats p as a
permission that r lent out. A write through r claims r's exclusive
access again, which ends every loan made from it.
That matches what the compiler may assume: after a write through an
exclusive r, nothing else should be able to change the value until r
lends access out again. If a later write through p were allowed, a
compiler that kept *r in a register would produce a different result
from one that reloaded it.
A practical rule follows: after deriving a raw pointer from a reference, finish using the raw pointer before writing through the reference again. Under Stacked Borrows, breaking this rule is undefined behavior, not a matter of style. The next section shows that Tree Borrows accepts this particular program, which is exactly why following the stricter rule is the safer choice.
Tree Borrows
Tree Borrows was introduced by Neven Villani, Johannes Hostert, Derek Dreyer and Ralf Jung (PLDI 2025). It records the pointers derived from each allocation as a tree: each reference is a child of the pointer it was created from. An access through one pointer is a child access for that pointer and its ancestors, and a foreign access for every other node. Each node holds a permission per memory location. The four main states are:
| State | Meaning |
|---|---|
| Reserved | the starting state of a &mut; it has not been written through yet, and reads through other pointers are still tolerated |
| Unique | a &mut that has been written through; a foreign access ends or reduces it (the paper calls this state Active) |
| Frozen | read-only; shared references to data outside UnsafeCell start here, and a written &mut becomes Frozen after a foreign read |
| Disabled | no access allowed; using the pointer is undefined behavior |
Miri also tracks variants of these states for data inside UnsafeCell and
for references passed to functions. Raw pointers do not get their own node;
they share the node of the reference they were derived from.
That last point means Tree Borrows accepts the first program above:
p and r share a node, so writing through r and then through p is
two child accesses to one node. With MIRIFLAGS=-Zmiri-tree-borrows the
program runs to completion. This is a concrete case where the two models
disagree.
Both models reject conflicting use of two independently created mutable
borrows. Here the temporary &mut x that produced p and the later r
are siblings in the tree:
fn main() {
let mut x = 0;
let p = &mut x as *mut i32;
let r = &mut x; // a second, independent mutable borrow of x
*r = 1; // foreign write for p's node: Reserved becomes Disabled
unsafe { *p = 2 }; // undefined behavior under both models
}
Tree Borrows reports:
error: Undefined Behavior: write access through <528> at alloc196[0x0] is forbidden
= help: the accessed tag <528> has state Disabled which forbids this child write access
Stacked Borrows rejects it too, but at an earlier point: creating r
already removes p's entry from the stack, so the write through p finds
no entry.
The Tree Borrows paper evaluated both models on the test suites of the 30 000 most widely used crates and reports that Tree Borrows rejects 54% fewer test cases than Stacked Borrows. That is a count of rejected test cases in one evaluation, not a general measure of how much more code the model allows.
Which model to rely on
Because the models disagree, a program can pass one and fail the other. For the first example the honest answer to "is this allowed?" is: Stacked Borrows rejects it, Tree Borrows accepts it, and the language has not decided. Miri labels both models experimental in its error messages, and code it accepts today could still be ruled undefined by a future decision.
The practical response is to write code that both models accept, prefer patterns the standard library documents, and treat "only Tree Borrows accepts this" as a reason to restructure the code rather than as permission.
Check your understanding Raw pointers are copied, stored and offset without creating new references. What does that suggest about how to structure unsafe code? Show answer Hide answer
Keep pointers raw for as long as possible and create references only where you need them, for as short a time as possible.
In both models, copying a raw pointer, storing it in a struct field or
computing an offset with add does not create a new tag or node. The raw
pointer keeps the permission of the reference or allocation it came from,
and each read or write through it is still checked against that permission.
New tags appear when you create a reference, such as &mut *p or &*p,
and when a reference is passed to a function as an argument.
This program writes through two copies of one raw pointer at different offsets and is accepted by both models:
fn main() {
let mut a = [0i32; 4];
let p = (&raw mut a).cast::<i32>();
let q = p;
unsafe {
*q.add(1) = 5;
*p.add(3) = 7;
}
assert_eq!(a, [0, 5, 0, 7]);
}
Creating two long-lived &mut from the same raw pointer makes them
siblings, and using the first after the second has written is undefined
behavior under both models. Stacked Borrows invalidates r1 as soon as
r2 is created; Tree Borrows disables r1 when r2 is written through:
fn main() {
let mut x = 0;
let p = &raw mut x;
let r1 = unsafe { &mut *p };
let r2 = unsafe { &mut *p };
*r2 = 1;
*r1 = 2; // undefined behavior under both models
}
Creating each reference only for the access that needs it is accepted:
fn main() {
let mut x = 0;
let p = &raw mut x;
unsafe { *(&mut *p) += 1 };
unsafe { *(&mut *p) += 1 };
unsafe { *p += 1 };
assert_eq!(x, 3);
}
The same reasoning explains why &raw mut x and &x as *const T are not
interchangeable. The second creates a shared reference first, so the raw
pointer inherits read-only permission; casting it to *mut T and writing
through it is undefined behavior under both models. (When the cast and the
write are in one place, rustc's deny-by-default invalid_reference_casting
lint rejects the code before it runs.) &raw mut x creates
the pointer without an intermediate reference. (&raw also matters for
places where creating a reference is itself invalid, such as unaligned
fields of a packed struct.) The
&raw const / &raw mut exercise
practices this.
What Miri detects
Miri detects many kinds of undefined behavior on the paths a program actually executes, including:
- out-of-bounds memory accesses;
- use after free;
- reading uninitialized memory as a value that must be initialized, such as an integer;
- misaligned accesses;
- invalid values, such as a
boolholding the byte 2 (see What is Undefined Behaviour? The complete list); - violated preconditions of unsafe operations and intrinsics, such as
unchecked_addoverflowing; - data races, plus some effects of weak memory ordering;
- aliasing violations, under the selected model.
It also reports memory leaks at program exit. A leak is not undefined
behavior, and -Zmiri-ignore-leaks turns the report off.
What Miri does not detect
- Code that did not run. Miri checks one execution. Undefined behavior on a branch your test never takes is not reported.
- Foreign code. A call to a C function is normally rejected as an unsupported operation. Miri provides its own implementations of many common standard library and operating system functions, and has experimental support for calling native libraries, but it cannot check what the foreign code itself does.
- Other layouts. Miri uses the type layouts the current compiler chose
for the target it is interpreting. Code that depends on an unspecified
layout, such as the field order of a struct without
#[repr(C)], can pass and still break with another compiler version or target. - Some pointer misuse. Miri warns that integer-to-pointer casts can make it miss pointer bugs, and Tree Borrows does not support them.
- Logic errors and performance problems, which are not undefined behavior.
A clean Miri run therefore does not prove that code is sound. An API is sound when no safe caller can cause undefined behavior through it, with any input and any schedule. Miri reports that the executions it ran contained no undefined behavior it could detect. That makes it a strong bug-finding tool, not a verifier. Ordinary test runs, including the graded exercises on this site, do not perform these checks at all.
Running it yourself
Miri requires a nightly toolchain. Each complete program on this page can
be pasted into src/main.rs of a scratch crate:
cargo new miri-scratch && cd miri-scratch
rustup toolchain install nightly
rustup +nightly component add miri
# paste a program into src/main.rs
cargo +nightly miri run # Stacked Borrows (default)
MIRIFLAGS=-Zmiri-tree-borrows cargo +nightly miri run # Tree Borrows
For a library, use cargo +nightly miri test in the same way.
Useful flags, set through the MIRIFLAGS environment variable:
| Flag | Effect |
|---|---|
-Zmiri-tree-borrows |
use Tree Borrows instead of the default Stacked Borrows |
-Zmiri-disable-isolation |
give the program access to host resources such as environment variables, files, the real clock and system randomness |
-Zmiri-many-seeds |
run the program once for each seed in a range (0..64 by default); each seed makes different nondeterministic choices, such as thread scheduling and allocation addresses |
-Zmiri-ignore-leaks |
do not report memory leaks |
Running your tests under both models is worthwhile, although Miri runs far slower than a native test run. If both models accept an execution, the result does not depend on which of the two you pick. If only one does, Miri's output shows the access where your code relies on a rule the models disagree about. Miri's own documentation adds that the final rules are likely to be stricter than Tree Borrows, so code that only Tree Borrows accepts is the riskier kind.
Check your understanding Your crate's whole test suite passes under Miri with both models. What have you established? Show answer Hide answer
That those executions, with those inputs and those thread schedules, contained no undefined behavior Miri could detect. That is useful evidence, and it is a minimum, not a guarantee.
It does not establish that untested branches are correct; that other
inputs cannot violate an aliasing rule; that a future decision about the
rules will not reclassify something you did; that your unsafe fn
contracts are stated correctly; or that the safe code in your module
cannot break an invariant the unsafe code relies on.
Safety invariants and privacy
covers that last point, which no execution-based tool can settle.
Treat a clean Miri run like a clean test run: necessary and informative,
but not a proof. The argument for soundness belongs in the SAFETY
comments, and it is only as good as the reasoning written there.
Summary
- Stacked Borrows (a stack of permissions per location, Miri's
default) and Tree Borrows (a tree of derived references with
permission states, enabled by
-Zmiri-tree-borrows) are two precise proposals for Rust's aliasing rules. Tree Borrows accepts some programs Stacked Borrows rejects. - Neither is official. Rust does not yet have a defined memory model, and the two models can disagree about the same program. Write code both accept.
- Both models attach permissions when references are created, so keep pointers raw and create references late and briefly.
- Miri checks the executions it runs. A clean run is evidence, not a proof of soundness.