← Back to Learning Hub

Borrow Checker  vs  NLL  vs  Polonius

How Rust's ownership enforcement has evolved β€” from the original lexical borrow checker, through Non-Lexical Lifetimes, to the next-generation Polonius engine.

The Big Picture

All three terms describe the same job β€” enforcing Rust's ownership and borrowing rules at compile time. The difference is how precisely each one tracks when borrows are alive.

πŸ“

Lexical Borrow Checker

Original (pre-2018). Borrows live for the entire lexical scope.

β†’
πŸ”¬

NLL

Rust 2018+. Borrows end at their last use, not end of scope.

β†’
🧠

Polonius

Next-gen. Uses origin analysis for even smarter flow-sensitive checking.

Key insight: Each generation accepts strictly more valid programs while rejecting the same set of unsound programs. Your code never gets less safe β€” just less annoying to write.

1. The Original (Lexical) Borrow Checker Pre-2018

The original borrow checker determined the lifetime of a reference from the lexical scope (the curly-brace block) in which it was created. A borrow lived from the let binding until the closing } β€” even if the reference was never used again.

Rules (unchanged across all versions)

The Two Laws of Borrowing

1. You can have either one &mut T (exclusive/mutable reference) or any number of &T (shared/immutable references) β€” never both at the same time.

2. References must always be valid β€” no dangling pointers.

The Problem: False Positives

Because the lexical checker tied lifetimes to scopes, it rejected many programs that were actually safe:

ERROR (pre-2018)
fn main() {
    let mut data = vec![1, 2, 3];

    let first = &data[0];        // immutable borrow starts here
    println!("{}", first);       // last use of `first`

    data.push(4);               // ❌ ERROR: `data` still borrowed!
}                               //    borrow of `first` ends HERE at `}`

The immutable borrow first was done being used after println!, yet the compiler kept it alive until the end of the block. This forced developers to use ugly workarounds like extra { } blocks to artificially shorten scopes.

WORKAROUND
fn main() {
    let mut data = vec![1, 2, 3];

    {
        let first = &data[0];    // borrow scoped to inner block
        println!("{}", first);
    }                           // borrow ends here

    data.push(4);               // βœ… OK now
}

2. Non-Lexical Lifetimes (NLL) Rust 2018 β€” Current

NLL, stabilised in Rust 1.31 (December 2018), was the single biggest ergonomics win in Rust's history. Instead of tying borrows to scopes, the compiler builds a control-flow graph (CFG) and computes the liveness region for every borrow β€” the set of program points where the reference might still be used.

How NLL Works

Liveness Analysis

A borrow is live at a program point if there exists a path from that point to a future use of the borrow. The borrow dies immediately after its last use β€” not at the end of the scope.

The previous example now compiles without any workaround:

OK (NLL)
fn main() {
    let mut data = vec![1, 2, 3];

    let first = &data[0];        // immutable borrow starts
    println!("{}", first);       // last use β†’ borrow ENDS here

    data.push(4);               // βœ… OK β€” no active borrow
}

Where NLL Still Struggles

NLL analyses borrows in a location-insensitive way for the origin (provenance) of references. It knows when a borrow is used but not always which specific data is borrowed through a given reference at each point. This leads to false rejections in conditional borrowing patterns:

ERROR (NLL)
fn get_or_insert(map: &mut HashMap<String, String>, key: &str) -> &String {
    if let Some(v) = map.get(key) {   // immutable borrow of `map`
        return v;                       // returning the borrow
    }

    // NLL thinks `map` might still be immutably borrowed here,
    // because the borrow *could* have flowed to the return value.

    map.insert(key.to_owned(), "default".into()); // ❌ mutable borrow
    map.get(key).unwrap()
}

The problem: NLL cannot see that the return v branch and the map.insert branch are mutually exclusive. If we reached insert, we didn't return, so the immutable borrow is dead β€” but NLL's analysis isn't precise enough to prove that.

3. Polonius Next-Gen β€” Nightly

Polonius is the next-generation borrow checker being developed to replace NLL's internal engine. Named after Shakespeare's character from Hamlet, it flips the analysis model on its head.

The Core Difference: Origins, Not Lifetimes

NLL asks: "Where does this borrow live?"

It computes a lifetime region β€” the set of program points where the borrow must be valid. Then it checks whether any conflicting access happens within that region.

Polonius asks: "Where did this reference originate?"

It tracks origins (also called "provenance") β€” which loan created a reference and where that loan's data flows. At each program point, it knows exactly which loans a reference could be carrying, and checks conflicts only for those specific loans.

This origin-sensitive, flow-sensitive analysis means Polonius can reason about conditional branches precisely. The example that fails with NLL works under Polonius:

OK (Polonius)
fn get_or_insert(map: &mut HashMap<String, String>, key: &str) -> &String {
    if let Some(v) = map.get(key) {
        return v;                       // loan flows to return β†’ branch exits
    }

    // Polonius knows: if we reach here, the loan from `map.get()`
    // did NOT flow to any live reference. It's dead.

    map.insert(key.to_owned(), "default".into()); // βœ… no conflict
    map.get(key).unwrap()
}

How Polonius Works (Simplified)

Polonius uses Datalog-style rules to compute three key relations over the CFG:

Relation Meaning
origin_contains_loan_at At program point P, origin O may contain loan L
loan_invalidated_at At point P, an action invalidates (conflicts with) loan L
errors At point P, loan L is both in some live origin AND invalidated β†’ error

By intersecting "which loans are reachable" with "which loans are invalidated" at each point, Polonius achieves precise, flow-sensitive, origin-sensitive analysis.

Side-by-Side Comparison

Feature Lexical Borrow Checker NLL Polonius
Era Rust 1.0 – 1.30 Rust 1.31+ (Rust 2018) Nightly (experimental)
Borrow ends at End of lexical scope } Last use of the reference Last use (with smarter flow analysis)
Analysis model Scope-based Liveness-based (CFG) Origin/provenance-based (Datalog)
Flow sensitivity None (lexical) Partial β€” lifetime regions are flow-sensitive, origins are not Full β€” both lifetimes and origins are flow-sensitive
Conditional borrows Poor β€” needs workarounds Better β€” but struggles with conditional return patterns Excellent β€” tracks which branch a loan flows through
Soundness Sound (overly conservative) Sound (less conservative) Sound (least conservative)
False positives Many Some Very few
Performance Fast Fast Actively being optimised

Practical Patterns Affected

Pattern 1: Use-then-mutate

OK since NLL
let mut v = vec![1, 2, 3];
let last = v.last();           // immutable borrow
println!("{:?}", last);        // last use
v.push(4);                     // βœ… NLL ends the borrow above

Pattern 2: Conditional borrow & mutate

OK with Polonius
fn get_default<'a>(map: &'a mut HashMap<i32, String>, key: i32) -> &'a String {
    if let Some(val) = map.get(&key) {
        return val;               // borrow exits via return
    }
    map.insert(key, String::from("default"));
    &map[&key]
}

Pattern 3: Self-referential insert-or-update

Needs Polonius
fn update_or_insert(map: &mut HashMap<i32, Vec<i32>>, key: i32, val: i32) {
    if let Some(vec) = map.get_mut(&key) {
        vec.push(val);           // mutably borrow map β†’ get inner vec
        return;
    }
    map.insert(key, vec![val]); // NLL may reject; Polonius accepts
}
Tip: Until Polonius stabilises, you can use the Entry API (map.entry(key).or_insert_with(|| ...)) to avoid these patterns entirely. The Entry API was designed precisely to work around borrow checker limitations.

Timeline

2015 β€” Rust 1.0

Lexical borrow checker ships. Developers learn to love (and fight) the borrow checker.

2016 β€” RFC 2094

Niko Matsakis proposes Non-Lexical Lifetimes. The community rallies around the idea.

2018 β€” Rust 1.31 (Edition 2018)

NLL is stabilised for the 2018 edition. Massive ergonomics improvement.

2019 β€” NLL for all editions

NLL becomes the default borrow checker for Rust 2015 edition as well (Rust 1.36).

2018–present β€” Polonius development

Polonius is developed as an experimental project. Available on nightly with -Z polonius. Active work on performance and integration.

Future

Polonius is expected to eventually replace NLL as the default borrow checker, once performance and compatibility are fully validated.

Try Polonius Today

You can experiment with Polonius on nightly Rust:

# Install nightly
rustup toolchain install nightly

# Compile with Polonius enabled
RUSTFLAGS="-Z polonius" cargo +nightly check

# Or set it in .cargo/config.toml
[build]
rustflags = ["-Z", "polonius"]
Note: Polonius is still experimental and may have longer compile times. Use it for testing whether a borrow error is a true bug or a false positive, but don't rely on it for production builds yet.

TL;DR

Borrow Checker β€” the umbrella concept. Rust statically enforces that references never dangle and that mutable access is exclusive. This guarantee has never changed.

NLL (Non-Lexical Lifetimes) β€” the current implementation. Borrows end at their last use, not at the end of the scope. Fixed the vast majority of "fighting the borrow checker" pain.

Polonius β€” the upcoming implementation. Tracks the origin of references through control flow, eliminating the remaining false positives that NLL cannot solve. Same safety guarantees, fewer false rejections.

All three uphold the same fundamental invariant: memory safety without garbage collection.