Guide contents

Lesson 77 of 94

Aliasing & Undefined Behavior

Safe Rust promises no undefined behavior (UB) in code without unsafe. But the promise rests on rules the compiler assumes instead of checking at runtime, and the most important of them is about aliasing: which references may point to the same memory at the same time. Everyone benefits from knowing these rules, because they explain the borrow checker's design. Anyone who writes or reviews unsafe code must know them, because breaking them is UB even when the code "works".

Topic Rust
Exclusive access &mut T is guaranteed to be the only active path to that data
Optimization The compiler knows &mut T and &T don't alias, and optimizes accordingly
Shared references &T means the data doesn't change while borrowed (except inside UnsafeCell)
Checking Safe code is checked or proven; unsafe code must uphold the rules itself
Undefined behavior No UB in safe code; UB in unsafe code can break anything, anywhere

This lesson shows a few unsafe blocks. Unsafe & FFI in the next section covers unsafe in depth; here the goal is to know the rules it must follow.

Part 1: What & and &mut promise

1. &mut means "no one else can see this right now"

The borrow checker's rule (either one &mut or any number of &) is not only about preventing bugs. It's a promise to the optimizer. Here, because total is &mut and step is &, the compiler knows they don't point to the same i32, so it can load *step once and keep it in a register.

Rust✗ does not compile · E0502
fn add_twice(total: &mut i32, step: &i32) {
    *total += *step;
    *total += *step; // the compiler may reuse the value of *step loaded above
}

fn main() {
    let mut x = 1;
    add_twice(&mut x, &x); // would make total and step the same memory
    println!("{x}");
}
Output
error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
 --> src/main.rs:8:23
  |
8 |     add_twice(&mut x, &x); // would make total and step the same memory
  |     --------- ------  ^^ immutable borrow occurs here
  |     |         |
  |     |         mutable borrow occurs here
  |     mutable borrow later used by call

In C without restrict, the compiler must assume that writing *total may change *step, and reload it after every write. Rust marks &mut parameters (and & parameters without UnsafeCell inside) with LLVM's noalias attribute, the equivalent of restrict on every reference. That's one reason idiomatic Rust loops vectorize well.

2. When aliasing is allowed, the results differ

Cell allows mutation through a shared reference, so two &Cell<i32> may point to the same cell. The same algorithm then gives a different answer when both arguments alias, which is exactly why the compiler may not use the optimization above for Cell.

Rust
use std::cell::Cell;

fn add_twice(total: &mut i32, step: &i32) {
    *total += *step;
    *total += *step;
}

fn add_twice_cells(total: &Cell<i32>, step: &Cell<i32>) {
    total.set(total.get() + step.get());
    total.set(total.get() + step.get()); // must re-read step: it may be `total`
}

fn main() {
    let (mut a, b) = (1, 1);
    add_twice(&mut a, &b);
    println!("separate values:  {a}");

    let (c, d) = (Cell::new(1), Cell::new(1));
    add_twice_cells(&c, &d);
    println!("separate cells:   {}", c.get());

    let e = Cell::new(1);
    add_twice_cells(&e, &e); // allowed: both are shared references
    println!("same cell twice:  {}", e.get());
}
Output
separate values:  3
separate cells:   3
same cell twice:  4

With the same cell passed twice, the first set changes step too, so the second addition adds 2 instead of 1. Rust's type system tells the compiler (and the reader) which of the two worlds a function lives in.

3. &T is truly read-only, unless there's an UnsafeCell

A shared reference promises that the data behind &T doesn't change at all while the reference is live. The single exception is data inside UnsafeCell<T>, and every type with interior mutability is built on it.

Type Contains UnsafeCell How it keeps mutation safe
Cell<T> yes copies values in and out; single-threaded (!Sync)
RefCell<T> yes runtime borrow counting; panics on conflict
Mutex<T>, RwLock<T> yes a lock grants &mut T to one thread at a time
AtomicU64 and friends yes atomic CPU instructions
OnceLock<T>, LazyLock<T> yes writes once, then only hands out &T
Vec<T>, String, your structs no only mutable through &mut

Because UnsafeCell is the only exception, turning a &T into a &mut T by casting is always UB. It's so reliably a bug that the compiler rejects the obvious form outright with the deny-by-default invalid_reference_casting lint:

Rust✗ does not compile
fn main() {
    let limit = 10;
    let r = &limit;
    let m = unsafe { &mut *(r as *const i32 as *mut i32) };
    *m = 20;
    println!("{limit}");
}
Output
error: casting `&T` to `&mut T` is undefined behavior, even if the reference is unused, consider instead using an `UnsafeCell`
 --> src/main.rs:4:22
  |
4 |     let m = unsafe { &mut *(r as *const i32 as *mut i32) };
  |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: for more information, visit <https://doc.rust-lang.org/book/ch15-05-interior-mutability.html>
  = note: `#[deny(invalid_reference_casting)]` on by default

If that compiled, the compiler could still print 10: it knows limit is immutable and may have used the constant directly.

4. Raw pointers don't carry the promise

Raw pointers (*const T, *mut T) may alias freely. Two *mut pointers to the same value, both used to write, are fine. That's what makes them the tool for code the borrow checker can't express, such as doubly linked lists and memory allocators.

Rust
fn main() {
    let mut counter = 0;
    let p1: *mut i32 = &raw mut counter;
    let p2 = p1; // a copy of the pointer: two aliasing *mut

    unsafe {
        *p1 += 1;
        *p2 += 1; // fine: raw pointers don't promise uniqueness
        *p1 += 1;
    }
    println!("{counter}");
}
Output
3

&raw mut counter creates a raw pointer without creating an intermediate &mut. The danger starts when raw pointers and references to the same data are mixed, which the next example shows.

5. Mixing references and raw pointers: Stacked and Tree Borrows

What exactly may you do with a raw pointer while a reference to the same data exists? Rust's official answer is still being finalized, but two precise models exist: Stacked Borrows and its successor Tree Borrows. Both treat every reference as a borrow with a lifetime that starts when it's created and ends when a conflicting access happens through another path. Using a reference after its borrow has ended is UB.

Rust
fn main() {
    let mut value = 0;
    let raw: *mut i32 = &raw mut value;
    let unique: &mut i32 = &mut value; // a new unique borrow of `value`

    unsafe {
        *raw = 1; // writes through an older path while `unique` is live
    }
    *unique += 1; // uses `unique` again: undefined behavior
    println!("{value}");
}

This program compiles, and with today's compiler it prints 2 in both debug and release builds. It's still UB: unique promised the optimizer that nothing else would touch value while it's live, so the compiler would be entitled to, for example, keep *unique in a register and ignore the write through raw.

The two models disagree only on where the UB is. Under Stacked Borrows, creating unique invalidates raw, so the write through raw is already UB. Under Tree Borrows, that write is allowed but disables unique, so the next use of unique is UB. Miri, Rust's UB-detecting interpreter, reports both:

Terminal
cargo +nightly miri run                                    # Stacked Borrows (the default)
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri run    # Tree Borrows
Text
error: Undefined Behavior: attempting a write access using <560> at alloc343[0x0], but that tag does not exist in the borrow stack for this location
 --> src/main.rs:7:9
  |
7 |         *raw = 1; // writes through an older path while `unique` is live
  |         ^^^^^^^^ this error occurs as part of an access at alloc343[0x0..0x4]
Text
error: Undefined Behavior: read access through <468> at alloc206[0x0] is forbidden
 --> src/main.rs:9:5
  |
9 |     *unique += 1; // uses `unique` again: undefined behavior
  |     ^^^^^^^^^^^^ Undefined Behavior occurred here

(Tag and allocation numbers vary between Miri versions.) The practical rules both models agree on:

  • While a &mut T is live, access the data only through that &mut (or pointers and references derived from it).
  • While a &T is live, don't write to the data through any path, except inside UnsafeCell.
  • Derive raw pointers from the reference you're currently using, not from an older one, and don't recreate references in a loop around raw-pointer writes.
  • Prefer working entirely with raw pointers inside an unsafe data structure, and create references only at the API boundary.

Part 2: Undefined behavior

6. Every type has valid values, and the compiler uses that

A bool is 0 or 1, a char is a valid Unicode scalar value, a reference is non-null and aligned, and an enum holds one of its declared variants. The compiler relies on these facts, for example to store Option's None in a value no valid T can have (a niche). Producing any other value, even without reading it later, is UB.

Rust
use std::mem::size_of;
use std::num::NonZeroU32;

#[allow(dead_code)]
enum Status {
    Active,
    Suspended,
    Closed,
}

fn main() {
    println!("bool {}, Option<bool> {}, Option<Option<bool>> {}", size_of::<bool>(), size_of::<Option<bool>>(), size_of::<Option<Option<bool>>>());
    println!("char {}, Option<char> {}", size_of::<char>(), size_of::<Option<char>>());
    println!("&u8 {}, Option<&u8> {}", size_of::<&u8>(), size_of::<Option<&u8>>());
    println!("NonZeroU32 {}, Option<NonZeroU32> {}", size_of::<NonZeroU32>(), size_of::<Option<NonZeroU32>>());
    println!("Status {}, Option<Status> {}", size_of::<Status>(), size_of::<Option<Status>>());
}
Output
bool 1, Option<bool> 1, Option<Option<bool>> 1
char 4, Option<char> 4
&u8 8, Option<&u8> 8
NonZeroU32 4, Option<NonZeroU32> 4
Status 1, Option<Status> 1

Option<bool> fits in one byte because None is stored as the byte 2, a value no bool may have. If unsafe code wrote 2 into a bool (for example with transmute), an Option<bool> holding it would read as None. This is why "invalid values" is UB even if you never branch on them: other code's representation depends on them.

7. Uninitialized memory is not "some random value"

Reading uninitialized memory as an integer, a bool, or anything else is UB, not "an unpredictable number". MaybeUninit<T> is the only correct way to hold memory that isn't initialized yet.

Rust
use std::mem::MaybeUninit;

fn main() {
    // Build an array of 4 strings without requiring Default or Copy.
    let mut slots: [MaybeUninit<String>; 4] = [const { MaybeUninit::uninit() }; 4];
    for (i, slot) in slots.iter_mut().enumerate() {
        slot.write(format!("worker-{i}"));
    }
    // Safety: every element was written in the loop above.
    let names: [String; 4] = unsafe { std::mem::transmute(slots) };
    println!("{names:?}");
}
Output
["worker-0", "worker-1", "worker-2", "worker-3"]

In real code, std::array::from_fn(|i| format!("worker-{i}")) does this without unsafe. The rules to remember: mem::zeroed() is only valid for types where all-zero bytes are a valid value (integers yes, references and NonZero no), and mem::uninitialized() is deprecated because it can't be used correctly for most types.

8. The list of undefined behavior

The Rust Reference lists what counts as UB. The items that matter in practice:

Undefined behavior Example of how unsafe code gets there
Data race (two unsynchronized accesses from different threads, at least one a write and at least one non-atomic) static mut written from two threads; a wrong unsafe impl Sync
Dereferencing a dangling, null, or misaligned pointer using a pointer after the Vec it points into reallocated
Breaking the aliasing rules of & and &mut example 5; two &mut to the same element
Mutating data behind &T outside UnsafeCell casting &T to &mut T (example 3)
Producing an invalid value a bool that isn't 0 or 1, an invalid enum discriminant, a null reference, an uninitialized integer
Calling a function with the wrong ABI or signature a wrong extern "C" declaration
Violating the safety contract of an unsafe fn get_unchecked(i) with i out of bounds; from_utf8_unchecked on invalid UTF-8
Reaching unreachable_unchecked() a "can't happen" branch that happens

UB is not "the program crashes". The compiler optimizes on the assumption that UB never occurs, so a program with UB can do anything: work in debug builds and fail in release, pass tests and fail after an unrelated change, or skip a bounds check it would otherwise have done. And the effect may show up far from the unsafe block that caused it.

9. What is *not* undefined behavior

Many things that are bugs, or even UB in C, are well defined in Rust, including in safe code:

Rust
fn main() {
    let big: u8 = 250;
    println!("checked:    {:?}", big.checked_add(10));
    println!("wrapping:   {}", big.wrapping_add(10));
    println!("saturating: {}", big.saturating_add(10));
    // `big + 10` panics in debug builds and wraps to 4 in release builds: both defined.

    let v = vec![1, 2, 3];
    println!("get(10):    {:?}", v.get(10)); // v[10] would panic, never read out of bounds

    let leaked: &'static mut Vec<i32> = Box::leak(Box::new(vec![1]));
    leaked.push(2);
    println!("leaked:     {leaked:?}"); // leaking is safe

    let r = std::panic::catch_unwind(|| {
        let empty: Vec<i32> = Vec::new();
        empty[0]
    });
    println!("panic caught: {}", r.is_err());
}
Output
checked:    None
wrapping:   4
saturating: 255
get(10):    None
leaked:     [1, 2]
panic caught: true

The panic message for empty[0] also goes to stderr. Also not UB: deadlocks, race conditions that aren't data races (such as the Relaxed bug in the previous lesson), stack overflow (the process aborts), running out of memory (it aborts), and integer division by zero (it panics). These are bugs, but they behave the same way every time.

Part 3: Writing and reviewing unsafe code

10. Soundness: a safe function must be safe for every input

The rule that keeps UB contained is soundness: a function without unsafe in its signature must not cause UB for any arguments, however strange. An unsafe block inside a safe function is a promise that the function checked everything needed.

Rust
/// Unsound: a caller can pass `i` out of bounds without writing `unsafe`.
#[allow(dead_code)]
fn nth_unsound(v: &[u64], i: usize) -> u64 {
    unsafe { *v.get_unchecked(i) }
}

/// Sound: the check makes the unsafe call valid for every input.
fn nth(v: &[u64], i: usize) -> Option<u64> {
    if i < v.len() {
        // Safety: `i < v.len()` was checked on the line above.
        Some(unsafe { *v.get_unchecked(i) })
    } else {
        None
    }
}

/// Also sound: the caller takes over the obligation.
///
/// # Safety
/// `i` must be less than `v.len()`.
unsafe fn nth_unchecked(v: &[u64], i: usize) -> u64 {
    // Safety: guaranteed by this function's contract.
    unsafe { *v.get_unchecked(i) }
}

fn main() {
    let v = [10, 20, 30];
    println!("{:?} {:?}", nth(&v, 1), nth(&v, 7));
    // Safety: 2 < 3.
    println!("{}", unsafe { nth_unchecked(&v, 2) });
}
Output
Some(20) None
30

nth_unsound compiles without a warning, which is why reviewers must check it. The rule also covers safety invariants of types: String must always hold UTF-8, and Vec's len must never exceed its capacity. Code that breaks them with unsafe (String::from_utf8_unchecked, Vec::set_len) causes UB later, inside perfectly safe methods.

11. Tools that find UB

Tool How to run Finds
Miri cargo +nightly miri test aliasing violations (Stacked or Tree Borrows with -Zmiri-tree-borrows), invalid values, uninitialized reads, out-of-bounds and use-after-free, data races, leaks
AddressSanitizer RUSTFLAGS=-Zsanitizer=address cargo +nightly test --target x86_64-unknown-linux-gnu out-of-bounds, use-after-free, and double free at native speed, including in C libraries
ThreadSanitizer RUSTFLAGS=-Zsanitizer=thread (see the previous lesson) data races
cargo careful cargo install cargo-careful, then cargo +nightly careful test runs tests with extra debug assertions in the standard library
Clippy #![warn(clippy::undocumented_unsafe_blocks)] unsafe blocks without a // Safety: comment
The compiler unsafe_op_in_unsafe_fn (warns by default in edition 2024) unsafe operations inside unsafe fn without their own unsafe block

Miri is the most important of these. It's slow (an interpreter, often 100x slower or more), so run it on unit tests of the modules that contain unsafe, in CI.

12. Checklist for reviewing unsafe code

Check Question to ask
Justification Is unsafe really needed? Is there a safe API (split_at_mut, array::from_fn, Cell, a crate)?
Safety comment Does every unsafe block say why its preconditions hold?
Soundness Can any input to the surrounding safe function trigger UB, including empty, huge, or aliased inputs?
Aliasing Is any &mut live while the same data is accessed through another path? Are raw pointers derived from the current reference?
Validity Can this produce an invalid bool, char, enum, reference, or uninitialized value?
Lifetimes Can a pointer outlive what it points into (Vec reallocation, a dropped String, a moved value)?
Threads Is every unsafe impl Send/Sync justified? Are atomic orderings strong enough?
Panics If code panics halfway, are invariants still upheld when destructors run?
Tests Is the module tested under Miri?

Summary

  • &mut T guarantees exclusive access and &T guarantees no mutation (except in UnsafeCell). The optimizer relies on both, like restrict on every reference.
  • Interior mutability (Cell, RefCell, Mutex, atomics, OnceLock) is all built on UnsafeCell. Casting &T to &mut T is always UB.
  • Raw pointers may alias freely. Mixing them with live references is where UB hides; Stacked Borrows and Tree Borrows define the rules, and Miri checks them.
  • Types have valid values the compiler relies on (niches). Invalid values and uninitialized memory are UB; use MaybeUninit.
  • Overflow, panics, leaks, deadlocks, and race conditions are bugs, not UB.
  • A safe function must be sound for every input. Document every unsafe block, and test unsafe modules with Miri.

This completes the Memory Management section. Next: Advanced Lifetimes, the start of the Advanced section.