Jump to content

Rust’s New Borrow Checker (Polonius) Is Coming

From JOHNWICK
Revision as of 14:57, 17 November 2025 by PC (talk | contribs) (Created page with "500px Rust has long been praised for combining performance and safety, but one of its most subtle — and at times frustrating — features is the borrow checker. It’s strict, quirky, sometimes surprising. Over the years, Rust’s community has pushed the boundaries of what the borrow checker accepts, culminating in non-lexical lifetimes (NLL). But there’s still more to do. Enter Polonius — a next-generation borrow checker (or, more p...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Rust has long been praised for combining performance and safety, but one of its most subtle — and at times frustrating — features is the borrow checker. It’s strict, quirky, sometimes surprising. Over the years, Rust’s community has pushed the boundaries of what the borrow checker accepts, culminating in non-lexical lifetimes (NLL). But there’s still more to do. Enter Polonius — a next-generation borrow checker (or, more precisely, a new formulation of borrow checking) that aims to be smarter, more precise, and more flexible than what we currently have.

In this article, I’ll take you on an architectural journey: what Polonius is, how it works (with internal code flows and diagrams), how it differs from NLL, some benchmarks and tradeoffs, and what it promises for the future. I’ll try to keep it as human as possible — there’ll be stories, curiosities, and even frustrations turned insights.

Why “Smarter Borrow Checking” Matters

Imagine you write this:

let mut x = 5;
let p = &x;
// Something in between
println!("{}", x);

With normal borrow rules, you can’t mutate or move x while p is alive, etc. But sometimes the compiler is too conservative: it rejects code that is in fact safe. The goal of Polonius is to reduce those false negatives—accept more correct programs—while preserving soundness.

One classic troublesome pattern is Problem Case #3 from NLL discussions: situations where reborrowing or switching pointers in branches lead to an overly strict rejection even when no undefined behavior is possible. Polonius is designed to handle these more gracefully. Small Cult Following+2Rust Blog+2

Additionally, Polonius is considered a stepping stone toward reasoning about self borrows or internal references, patterns that current borrow checking struggles with. Small Cult Following+2Rust Blog+2

High-Level Architecture & Flow

Let me begin with a big picture, then we’ll dive into pieces with examples and internal flows.

Overview: Modules and Phases

When implementing Polonius, the compiler pipeline (roughly) does:

  • Lowering to MIR
Like the existing system, borrow checking works on MIR (Mid-level Intermediate Representation). The source code is desugared, control-flow simplified, etc.
(This is unchanged.)
Small Cult Following+2Rust Blog+2
  • Compute Liveness / Live Origins
Polonius needs to know, at each program point, which origins (i.e. where a borrow may come from) are live. This is analogous to how NLL computes liveness of lifetimes.
Small Cult Following+1
  • Build “Outlives / Subset” Constraint Graphs (Global & Local)
In Polonius, a lifetime (or origin) is modeled as a set of loans. The relationships that some origin outlives another become subset constraints among sets of loans. Importantly, Polonius supports location-sensitive (i.e. per program point) outlives constraints.
Rust Programming Language+3Small Cult Following+3Diva Portal+3
  • Loan Activity / Dataflow / Fixpoint Analysis
For each loan (i.e. each borrow), Polonius figures out exactly at which CFG locations it is active, using a location-sensitive, bidirectional propagation of region/loan membership.
Rust Programming Language+3Diva Portal+3Rust Blog+3
  • Error Checking / Diagnostics
Once you know which loans are active where, you can check borrow-read, borrow-write, move conflicts, etc., according to Polonius’s rules. Because Polonius is more precise, sometimes it can rule out conflicts that NLL had flagged, and hence accept safe code.
Rust Programming Language+3Diva Portal+3Rust Blog+3

So the key novelty in Polonius is its location-sensitive constraint system and fine-grained propagation of loan-sets through control-flow, rather than the coarser, location-insensitive approach used by NLL. Rust Programming Language+3Small Cult Following+3Rust Blog+3

Control-Flow Diagram (Simplified)

Here is a conceptual flowchart of how things get computed:

Source code
     │
     ▼
 Lower to MIR (CFG + basic blocks)
     │
     ▼
 Compute live origins per point (dataflow)
     │
     ▼
 Build global & local subset (outlives) constraints
     │
     ▼
 For each loan:
   Initialize at its “reserve” point
   Propagate loan membership sets across CFG (bidirectional, with kills)
   Reach fixed point
     │
     ▼
 For each point, check: is the loan active here?
     │
     ▼
 Use active-loan info to detect conflicts (read/write/move)
     │
     ▼
 Emit errors or accept

The “propagate” step is the heart: it mixes forward propagation (loan continues forward) and backward propagation (some constraints may require revisiting earlier points), constrained by kills and liveness.

One subtlety: universal origins (lifetimes like 'static or generic parameters) don’t backward-propagate. Diva Portal+1

Detailed Example and Code Flow

To really feel Polonius, let’s run through an example adapted from blog explanations. I’ll also illustrate the internal data: origins, loans, subset constraints, and propagation.

Example

let mut x = 22;
let mut y = 44;


let mut p: &'0 u32 = &x;   // Loan L0 from x
y += 1;                    // (A)
let mut q: &'1 u32 = &y;   // Loan L1 from y
if cond() {
    p = q;                 // (B) reassign p to reference y
    x += 1;                // (C) mutate x
} else {
    y += 1;                // (D) mutate y
}
y += 1;                    // (E)
read_value(p);             // (F)

Under the existing borrow checker, errors appear at D and E, even though D is actually safe: the borrow of q is never used in that branch, so mutating y is okay. Polonius can accept D.

Origins, Loans, and Constraints

  • &x introduces Loan L0, associated with origin '0.
  • &y introduces Loan L1, associated with origin '1.

Initially, subset / outlives constraints are:

  • '0 ⊆ '0 (trivial)
  • '1 ⊆ '1
  • On the assignment p = q: we get a local constraint at that point: '1 ⊆ '0 (i.e. origin 1 is a subset of origin 0 at that point). Rust Programming Language+3Small Cult Following+3Rust Blog+3

Because this constraint is local to that branch, it doesn’t globally force '1 ⊆ '0 everywhere, just in that region of the CFG. That’s the power of location-sensitive constraints.

Propagation of Loan Membership

Let me outline propagation of which loans are “in” each origin set at each point (somewhat simplified):

  • At the start, origin '0 includes L0, '1 includes L1, everywhere until changed.
  • In the then-branch at the assignment p = q:
  • The local constraint says '1 ⊆ '0, so '0 now also includes L1 in that branch.
  • Also, because p = q, the original L0 is “killed” (p no longer references x). So in that branch, '0 contains only L1.
  • When we reach the mutation x += 1 in the then branch:
  • We check if L0 (or any other loan in '0 at that point) conflicts with mutating x. But since '0 now only has L1, which refers to y, there's no conflict. So (C) is accepted.
  • In the else branch, there is no p = q, so '0 remains containing L0. There, when y += 1 (D) appears:
  • The borrow L1 is present in '1 but not in '0 (because we didn’t propagate '1 into '0 in that branch).
  • Since p (which refers to '0) is not referencing y, mutating y is safe. Polonius sees that and allows D.
  • At E, mutation y += 1 after the if:
  • Because in some paths '0 may contain L1 (due to propagation from then branch), at E, Polonius recognizes the loan may still be active in '0, so it flags (E) as an error.
  • Finally, reading p at F is fine as long as the loan’s origin is active there.

Thus, Polonius rejects E but accepts D — exactly the intuitive result.

This behavior is impossible with NLL’s location-insensitive constraints, because NLL would globally force '1 ⊆ '0 (once it's true in one branch), making D error too. Small Cult Following+2Rust Blog+2

Internal Implementation Sketch (Pseudocode)

Here’s a rough pseudo-Rust sketch of how the loan activity analysis might look inside Polonius. (This is illustrative, not actual compiler code.)

struct OriginSet {
    loans: BitSet<LoanId>,
}


struct LocalConstraints {
    // For each program point, a list of subset constraints
    at_point: Vec<Vec<(OriginId, OriginId)>>,  // (sub, sup) meaning sub ⊆ sup
}

fn compute_loan_activity(
    mir: &Mir,
    live_origins: &Vec<BitSet<OriginId>>,
    global_constraints: &[(OriginId, OriginId)],
    local_constraints: &LocalConstraints,
    kills: &KillMap,  // map: (point, loan) -> killed?
) -> Vec<BitSet<LoanId>> {
    // For each origin at each point, we'll have the set of loans
    let mut origin_loans: Vec<Vec<OriginSet>> = init_origin_sets(mir, live_origins);
    // maybe a worklist of (point, origin)
    let mut worklist = VecDeque::new();
    for point in 0..mir.num_points {
        for oi in 0..mir.num_origins {
            worklist.push_back((point, oi));
        }
    }
    while let Some((point, origin)) = worklist.pop_front() {
        let mut set = &mut origin_loans[point][origin];
        let before = set.loans.clone();
        // Expand via subset constraints active at this point
        //   global constraints always apply
        //   local ones apply if point matches
        for &(sub, sup) in global_constraints {
            if sup == origin {
                // sub ⊆ sup → union sub.loans into sup.loans
                let sub_loans = &origin_loans[point][sub].loans;
                set.loans.union_with(sub_loans);
            }
        }
        for &(sub, sup) in &local_constraints.at_point[point] {
            if sup == origin {
                let sub_loans = &origin_loans[point][sub].loans;
                set.loans.union_with(sub_loans);
            }
        }
        // If loan is killed at this point, remove it
        for &loan in &before {
            if kills.is_killed(point, loan) {
                set.loans.remove(loan);
            }
        }
        if set.loans != before {
            // changed: re-enqueue predecessors and successors
            for pred in mir.predecessors(point) {
                worklist.push_back((pred, origin));
            }
            for succ in mir.successors(point) {
                worklist.push_back((succ, origin));
            }
        }
    }
    // After fixpoint, build per-point "active loans"
    let mut active_loans = vec![BitSet::new(); mir.num_points];
    for point in 0..mir.num_points {
        for oi in 0..mir.num_origins {
            if live_origins[point].contains(oi) {
                active_loans[point].union_with(&origin_loans[point][oi].loans);
            }
        }
    }
    active_loans
}

Then later, when checking a borrow conflict at some point:

fn check_conflict(
    point: Point,
    loan: LoanId,
    op: Operation,  // Read, Write, Move, etc.
    place: PlaceId,
    active_loans: &Vec<BitSet<LoanId>>,
) -> bool {
    // If `loan` is active at that point AND op conflicts with its place, it's an error
    if active_loans[point].contains(loan) && conflicts(op, &loan_place(loan), place) {
        return true;
    }
    false
}

This is a simplified model. The actual implementation has optimizations, caching, incremental worklists, and more nuanced handling of universal origins, propagation directions, etc. Rust Programming Language+3Diva Portal+3Rust Blog+3

Benchmarks & Performance Tradeoffs

One major challenge with Polonius is scalability: more precise analysis often means more compute. The original Polonius prototype (in Datalog) struggled with performance regressions, which is why NLL shipped first. Rust Internals+2Rust Blog+2

The Rust project has a goal to make a “scalable Polonius” implementation live on nightly that can run the full test suite and crater without large slowdowns. Rust Programming Language

From the Inside Rust blog (October 2023), the team outlines that earlier implementations had performance and memory issues, but more recent re-implementations aim to reduce regressions and bring Polonius to parity (or near-parity) with NLL. Rust Blog

A few points to note (from blog / project goals):

  • The Polonius implementation is gated under a -Z feature flag to avoid destabilizing performance on stable.
  • The goal is “little to no diagnostics regressions” — meaning existing code should keep working, and performance should not degrade significantly. Rust Programming Language+2Rust Blog+2
  • Polonius’s architecture is being tuned to avoid blow-up in large codebases by localizing constraints, caching, and avoiding full-blown per-point heavy graphs. Rust Programming Language+2Rust Blog+2

I (personally) haven’t yet seen a published benchmark table showing “NLL vs Polonius compile time on crates X, Y, Z.” If I find one later, I’ll update this article. (If you like, I can dig deeper and fetch that.)

But conceptually, the tradeoff is:

  • Pros of Polonius: more precise, fewer false errors, ability to accept more safe code, groundwork for self-borrows.
  • Cons: more complex analysis, potentially more memory and CPU overhead, risk of regressions for big codebases.

The hope is that with careful engineering (incremental algorithms, caching, locality) Polonius can get very close to NLL in performance but with strictly greater precision. That’s the mission. Rust Programming Language+2Rust Blog+2

Key Points & Emotional Journey

As I’ve absorbed Polonius, here are some distilled “lessons learned” and emotional notes (yes — I care about feelings too):

  • Precision over conservatism
Polonius is pushing Rust from “safe but sometimes annoying” toward “safe and permissive where possible.” It’s like giving the compiler more trust to understand your logic. It’s satisfying when code you thought was valid but was rejected now compiles clean.
  • Complexity is inevitable
Borrow checking is hard. To get more precision, you can’t just tweak a few rules — you need a more expressive, data-driven system (origins, subsets, location sensitivity). That complexity is the price of smarter checking.
  • Locality is your friend
The trick that enables Polonius to scale is that outlives / subset constraints can be local to program points. That avoids forcing global constraints everywhere and keeps propagation more efficient. It’s a powerful design principle.
  • Backward propagation is surprising but necessary
The fact that Polonius sometimes has to go backwards in the CFG (to refine origin sets) is maybe unintuitive. But it’s what lets it correctly understand constraints that “reach back” through control flow. This also complicates the fixpoint algorithm.
  • Engineering matters
All the cleverness would be moot if Polonius were unbearably slow. The current challenge is not just soundness, but performance. Watching the compiler team balance precision vs compile-time slowdowns gives me deep respect for systems work.
  • Promise of new patterns
I’m excited about internal references, self-borrows, and “lending iterators” (one of the use cases driving Polonius) that may be supported more naturally. Polonius isn’t just about tweaking existing borrow checking — it’s opening new doors. Rust Programming Language+1

Summary & What to Watch Next

Rust’s borrow checker has been a central part of its memory safety guarantees — but also a source of frustration when correct code is rejected. Polonius represents a bold step in evolving that system: more precise, more flexible, smarter. Some takeaways:

  • Polonius models lifetimes/origins as sets of loans and supports location-sensitive subset constraints.
  • Its core algorithm propagates loan membership bidirectionally across the control-flow graph, respecting local constraints and kills.
  • In practice, it can accept patterns that NLL rejects (like Problem Case #3 variants).
  • The main barrier now is engineering: keeping performance overhead minimal and scaling to real-world codebases.
  • If all goes well, Polonius may become the default borrow checker, enabling new Rust patterns around internal references.