Internals
Internals

The borrow checker

Non-lexical lifetimes, MIR-level analysis, two-phase borrows, the upcoming Polonius. Rust's central trick — proving memory safety at compile time by tracking which references are live where, and rejecting any code that could observe a moved or mutated value through a stale alias.

Long read · borrow-checker basics through to the implementation in rustc · references at the end


1 · What it's enforcing

Rust's two-line rule: at any point in the program, for any given value, you either have one &mut T reference, or any number of &T references — never both at once. The borrow checker proves this statically for every function.

rust src/main.rs · the rejection
fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];   // immutable borrow

    v.push(4);           // mutable borrow ← rejected

    println!("{}", first);  // immutable borrow still live
}

The compiler can prove v.push(4) might reallocate the vec — invalidating first. So it rejects.

2 · Lexical lifetimes (old) vs NLL (current)

Before Rust 2018, lifetimes were lexical: a borrow lasted from declaration to the closing brace of its scope. This was conservative — code like the example below was rejected even though it's clearly safe.

rust src/main.rs · used to be rejected
fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];

    println!("first: {}", first);   // last use of `first`

    v.push(4);                       // OK with NLL — `first` is dead by now
    println!("{:?}", v);
}
NLL (Non-Lexical Lifetimes). A borrow lives only from creation to its last use. The compiler computes a precise borrow region — the set of program points at which the borrow is live — and only those points count for the aliasing check.

3 · MIR — the intermediate representation

The borrow checker doesn't run on the Rust source. It runs on MIR (Mid-level IR), an explicit control-flow graph where every assignment, drop, borrow, and re-borrow is a named statement. MIR makes "the set of program points where reference X is live" a well-defined question.

shell terminal · inspect MIR
$ rustc --emit=mir -O src/lib.rs
$ cargo rustc --release -- --emit=mir
$ # Or in the playground: tools → MIR

# Example MIR fragment for "let first = &v[0]"
_3 = &_1[0_usize];             // _3 is the borrow
StorageLive(_4);
_4 = move _3;                  // move into 'first'

The borrow at _3 has a region — the set of basic blocks where _4 is live. The check fails if any other borrow of the same place overlaps with that region.

4 · Two-phase borrows

Without this, v.push(v.len()) would be rejected: push wants &mut v as the receiver, and v.len() wants &v at the same call site. Two-phase borrows split the mutable borrow into a "reserve" phase (which permits concurrent shared reads) and an "active" phase (which is exclusive).

rust src/main.rs
fn main() {
    let mut v = vec![1, 2, 3];
    v.push(v.len() as i32);   // works — two-phase borrow
    println!("{:?}", v);
}

The mutable borrow of v for push is in its reserve phase while v.len() runs. Only when push actually executes does the borrow become active.

5 · Polonius — the future

The current borrow checker uses a region-based dataflow analysis. Polonius re-frames the problem in Datalog and works the other way around: it asks "from where is this borrow used?" rather than "where is this borrow live?". It accepts strictly more programs — including the famous "Problem case #3":

rust src/lib.rs · still rejected by NLL, accepted by Polonius
fn get_default<'r, K, V>(map: &'r mut HashMap<K, V>, key: K) -> &'r mut V
where K: Eq + Hash, V: Default {
    match map.get_mut(&key) {
        Some(v) => v,                            // borrow #1
        None => {
            map.insert(key, V::default());       // wants &mut — conflicts in NLL
            map.get_mut(&key).unwrap()           // borrow #2
        }
    }
}

NLL conservatively keeps borrow #1 alive on the None arm. Polonius proves it's dead there. Polonius is partially merged in nightly; eventual stabilisation will quietly accept thousands of programs that currently need workarounds.

6 · How errors are produced

The checker doesn't just say "borrow check failed". It walks the conflict back to find the three program points: (a) where the conflicting borrow started, (b) where it's used, and (c) where the original borrow is live. The error message reads like a stack trace because it is a stack trace through the MIR.

shell terminal
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
  --> src/main.rs:5:5
   |
 3 |     let first = &v[0];
   |                 ----- immutable borrow occurs here
 4 |
 5 |     v.push(4);
   |     ^^^^^^^^^ mutable borrow occurs here
 6 |
 7 |     println!("{}", first);
   |                    ----- immutable borrow later used here

7 · What the checker doesn't catch

  • Logic bugs. Aliasing safety isn't correctness. Your code can be borrow-checked and still wrong.
  • Leaks. std::mem::forget and Rc cycles bypass Drop. Memory safe, but the destructor never runs.
  • Deadlocks. Lock acquisition order is runtime behaviour; the type system doesn't prevent it.
  • Interior unsafety bugs. An unsafe block can violate aliasing rules. The check trusts the unsafe author.

8 · Working with the checker

  • Split borrows. If you need two mutable references into different fields, split them: let (a, b) = struct_split(&mut s) (or use destructuring patterns directly).
  • Indices over references. When pure iteration won't do, hold an integer index instead of a pointer; the integer doesn't borrow anything.
  • Clone where cheap. For small types (Copy), or types behind Rc, cloning is the right answer rather than fighting the checker.
  • RefCell as escape hatch. When you can't prove aliasing at compile time but you can at runtime — interior mutability moves the check to runtime.

9 · Two-phase borrows — the small special case

Two-phase borrows exist because the obvious desugaring of a single line of method-call code, under the strict aliasing rule, would reject too much working code. Consider:

rust src/main.rs · the method call that requires special treatment
let mut v = vec![1, 2, 3];
v.push(v.len());
//        ^^^^^^^ reads v while v is also being mutably borrowed for push
//
// Strict NLL would reject this: push() takes &mut v starting from the moment
// the receiver expression is evaluated; v.len() inside the argument list then
// tries to take &v on a value already mutably borrowed. Rejection.

The fix in 2018-era Rust is that the borrow checker recognises method-call shape and splits the mutable borrow into two phases. Phase one (reservation): the receiver is captured and reserved, but the reservation does not yet conflict with other borrows. The argument expressions are evaluated under whatever borrows they need. Phase two (activation): when control enters the method body, the reservation becomes a real mutable borrow.

The result is that v.push(v.len()) compiles. The mutable borrow on v is reserved during the call-site evaluation, the immutable v.len() runs while only a reservation exists, and the mutable borrow activates once push starts running. The order of operations matches what humans expect from "first compute the arguments, then call".

Two-phase borrows are limited to a small set of expression shapes. Method calls where the receiver is mutably borrowed and arguments need an immutable borrow of the same place are the canonical case. The borrow checker rejects more exotic patterns; for those, the fix is to evaluate the argument into a local variable first.

10 · Polonius — the rewrite that took years

Niko Matsakis (long-time Rust compiler lead) framed the limitation of NLL in 2018 with a single example:

rust src/main.rs · the case NLL rejects, Polonius accepts
fn get_default<'r>(map: &'r mut HashMap<K, V>, key: K) -> &'r mut V {
    match map.get_mut(&key) {
        Some(v) => v,                          // <- requires &mut map for 'r
        None    => map.insert(key, V::default()) // <- error: map already &mut
    }
}
// NLL: rejects. The &mut from get_mut() is considered live throughout the
//   whole match arm (in case None branch needs it), conflicting with insert.
// Polonius: accepts. The &mut from get_mut() is only live on the Some path.

The NLL borrow checker tracks lifetimes by region — a borrow is "live" everywhere its lifetime is annotated as covering. Polonius reformulates the same check as a Datalog-style fact base: a borrow is live at a point P if its origin can reach P along some control-flow path where it is used. That formulation lets the checker prove the get_default function safe because the borrow from get_mut() is only used on the Some path, not the None path.

Polonius has been in development for years. The 2026 status: it compiles and produces correct results, but is slower than NLL on most code and has not been promoted to the default checker yet. The plan, when it does ship, is for Polonius to silently accept more programs without breaking any existing ones — purely a relaxation, not a behaviour change.

What this means for working Rust today: the small number of patterns Polonius accepts and NLL rejects are documented; the workaround is usually to introduce an intermediate variable or restructure the match arms. The day Polonius lands, those workarounds become unnecessary, but no existing code breaks.

11 · Lifetime variance — the small piece nobody learns first

Variance is the rule the borrow checker uses for "if I have a reference with lifetime 'long, can I assign it to a slot expecting lifetime 'short". The intuition: longer-living references can be used where shorter-living ones are expected. So &'long T is a subtype of &'short T when 'long outlives 'short — covariant in the lifetime.

The complication: this only holds when the lifetime appears in a covariant position. &mut T is invariant in T — you cannot substitute a different lifetime even when one outlives the other — because mutation lets the borrower write something that the original reference might then read at the wrong lifetime.

rust src/main.rs · variance in practice
// &T is covariant in T: this works.
fn shorter<'short>(_: &'short String) {}
fn caller<'long: 'short, 'short>(r: &'long String) {
    shorter(r);   // ok: &'long String coerces to &'short String
}

// &mut T is invariant in T: this would NOT work.
//   passing a &mut Vec<&'long u8> where &mut Vec<&'short u8> is expected
//   would let the callee write a 'short reference into a 'long-typed Vec,
//   which the caller could then read at the wrong lifetime.

// Cell<T> is invariant in T for the same reason — interior mutation.

The variance rules show up in compile errors most often when working with custom lifetime-parameterised types. Defining struct Wrap<'a, T>(&'a T) gives you a wrapper covariant in 'a and T; defining struct Wrap<'a, T>(*const T, PhantomData<&'a T>) with the wrong PhantomData can change variance in surprising ways. The Rustonomicon's variance chapter is the reference document.

Almost no working Rust code needs to think about variance directly. The case where it matters is writing a smart-pointer or container type that wraps references; getting variance wrong there produces compile errors at call sites that look unrelated to the pointer definition. The fix is usually to consult the variance table for the underlying construct (raw pointer, PhantomData of various shapes) and pick the one that matches the intended subtyping behaviour.

12 · Reading the error messages

The borrow checker's error messages are the most-improved part of the language over the last five years. The 2026 format is helpful enough that "the compiler told me what to do" is a workflow rather than a punchline. A few patterns worth knowing how to read:

"cannot borrow X as mutable because it is also borrowed as immutable". Means: somewhere above the error site, you created an immutable reference into X that is still live. The error gives you both the original borrow location and the conflicting borrow. The fix: end the immutable borrow before the mutable one starts (often by restructuring the lines, or by extracting the read into a local).

"X does not live long enough". The borrow refers to a value whose scope ends before the borrow is last used. The error shows the value's scope ("borrowed value dropped here") and the borrow's use site ("borrow later used here"). The fix is usually to extend the value's scope (declare it at a higher level) or to clone the value if it is cheap.

"cannot move out of borrowed content". You took a reference and then tried to consume the underlying value. The fix: clone, or restructure so the value moves before being borrowed.

"closure may outlive the current function". You created a closure that captures a local by reference, then tried to pass that closure somewhere that requires 'static. The fix: use move on the closure to capture by value, or extract the captured data into a clone first.

"the size of values of type cannot be known at compilation time". Usually means you forgot a ?Sized bound, or you tried to put a dyn Trait in a position requiring a sized type. The fix: Box<dyn Trait> or &dyn Trait.

Once you have the pattern in your head, the error messages stop being noise. The suggestion lines (the ones starting with "consider...") are usually correct enough that applying them blindly fixes the error.

13 · The cost the borrow checker imposes

Honest accounting. The borrow checker buys memory safety at compile time, with three real costs:

Compile time. The MIR borrow-check pass is a measurable fraction of rustc time. On a large crate (the rustc source itself, tokio, hyper) borrow-checking can be 10-20% of the build. Polonius is currently slower; landing it has been gated partly on closing that gap.

API design constraints. Some Rust APIs are uglier than their GC'd-language equivalents because the borrow checker would not accept the natural shape. Iterator combinators, the Iterator::scan pattern, the cell-of-borrowed- handles work-around for trees and graphs — these exist because the simpler designs cannot be expressed safely. The community generally considers the trade worth it; people coming from other languages disagree, often loudly.

The learning cliff. The "Rust is hard" reputation is mostly the borrow checker. Productive Rust starts somewhere around month three for most developers. The tooling has improved enough that the cliff is shallower than it was, but it is still there.

Against those costs: zero garbage collector, predictable runtime cost, the elimination of an entire class of bugs (use-after-free, data races, iterator invalidation) that account for a sizeable fraction of production CVEs in C and C++ codebases. The Rust experience is that once the cliff is climbed, the day-to-day rate of memory-related bugs drops to near zero. Whether the cliff is worth the drop is the language's bet; the borrow checker is how it places that bet.

References

Found this useful?