Partial Borrows: The Borrow Checker's Oldest Paper Cut

AI authored to showcase ironpad capabilities.

Introduction

Every Rust developer has personally lost this fight. You have a struct, it has two fields, and you want to hand one method the first field and another method the second field, mutably, at the same time. The compiler says no, some variant of:

error[E0499]: cannot borrow *self as mutable more than once at a time

You know the two methods touch disjoint fields. The borrow checker does not, because it never looks inside a method to find out. On a slice you get a pressure valve: split_at_mut hands you two disjoint &mut halves and everyone goes home happy. On the fields of a struct, through methods, there has never been an equivalent. This is one of the oldest paper cuts in the language.

In 2026 this paper cut finally has three answers moving at once.

  1. A crate, borrow v2, that ships the future syntax today, on stable.
  2. Niko Matsakis' "maximally minimal view types" (March 21, 2026), the language-side proposal.
  3. The Reborrow traits 2026 project goal, already landing on nightly for the easy cases.

We are going to hit the wall on purpose, watch a crate walk straight through it, and read the tea leaves on which of the three lands first. There is a Polonius sidebar at the end too, because the other great borrow-checker modernization deserves a look.

The wall, live

Below is a Graph with two fields, nodes and edges, and two methods that each borrow exactly one of them. We grab a mutable handle to each, back to back, and try to use both. This cell does not compile, and that is the whole point. ironpad renders the compiler's diagnostic inline, right where rustc points.

(Our borrow here is a local we named g rather than self, so the headline names g instead of *self: cannot borrow g as mutable more than once. Same E0499, same lesson.)

The wall (does not compile)

The same wall, three more ways

The two-getter demo above is the lab-conditions version. In real code the wall shows up in messier shapes; here are three, in markdown fences because none of them compile. The failure is the lesson.

Case one: iterate one field, log through a method. The one that gets everybody, usually mid-loop:

struct Editor {
    buffer: Vec<String>,
    history: Vec<String>,
}

impl Editor {
    fn log(&mut self, msg: &str) {
        self.history.push(msg.to_string());
    }
    fn shout(&mut self) {
        for line in &mut self.buffer {
            line.push('!');
            self.log("shouted"); // error[E0499]: cannot borrow `*self` as
        }                        // mutable more than once at a time
    }
}

The loop holds &mut self.buffer for its whole body; self.log(...) demands &mut self, all of it, because that is what the signature says. Two overlapping mutable borrows: E0499, even though the bodies touch disjoint fields.

Case two: hold a reference from one field across a method call:

impl Editor {
    fn commit(&mut self) {
        let last = self.buffer.last().unwrap(); // & borrow of self.buffer
        self.log("committing");                 // error[E0502]: cannot borrow
        assert!(!last.is_empty());              // `*self` as mutable because it
    }                                           // is also borrowed as immutable
}

E0502 instead of E0499, same disease. log only touches history, last only touches buffer, and the signature flattens both facts into "all of self".

Case three: the refactor that breaks the build. Inline code compiles; extract a method for hygiene and some call sites stop compiling:

// Inline: compiles. The checker sees two disjoint field borrows.
graph.nodes.push("D".into());
graph.edges.push((2, 3));

// Behind a method, the SAME mutation now claims all of graph:
fn add_node(&mut self, label: &str) { self.nodes.push(label.into()); }

Inline field access is checked place by place; behind &mut self, that precision collapses to the whole struct. Behind a method, whatever the body does, the signature claims the entire value. Extracting a method, the most basic act of hygiene in the language, can shrink what the checker accepts. That asymmetry is why this bug report never closes.

The signature is the contract

Read nodes_mut again: fn nodes_mut(&mut self) -> &mut Vec<String>. At the call site, that signature is all the compiler is allowed to know. It says "I borrow all of self mutably, and I hand you back a reference tied to that borrow." The body clearly only touches self.nodes, but the borrow checker refuses to read the body to find that out, and for good reason. If it did, every caller would be coupled to the implementation of every method, and changing a method body could break code three crates away. Signatures are the operative boundary. The checker reasons about the signature, and nothing past it.

That is also exactly why split_at_mut works and your two methods do not. Its signature promises disjointness right in the return type, (&mut [T], &mut [T]), two independent borrows spelled out where the caller can see them. So the whole game is to give an ordinary struct a way to say that same thing in a signature. That is what all three of 2026's answers are really about.

The load-bearing fact

All three 2026 answers, and every workaround, follow from one fact: the borrow checker is intraprocedural. It checks one function body at a time, and everything it knows about a callee is the callee's signature. It never inlines, never peeks, never runs whole-program analysis.

That is deliberate, and correct, for three compounding reasons. Compile time: per-function checking scales linearly with your crate, while body-sensitive inference would re-verify the world every time a leaf function changed. Semver: if callers could rely on what a body does, every body would be de facto public API, and widening a method's footprint from nodes to nodes plus edges would become an invisible breaking change. Privacy: an analysis reasoning about private fields from outside the module dissolves exactly the boundary privacy exists to draw.

So the checker reads signatures only, which localizes the actual language bug: today's signature grammar cannot say "I borrow only self.nodes". The vocabulary is &self, &mut self, and lifetimes; a borrow through a reference covers the whole referent. &mut self means all of self, at every call site, forever.

Everything else in this notebook is one of two moves against that fact. Move one: relocate the split to the call site, where the checker already reasons precisely about places (three of the four workarounds below). Move two: extend the signature grammar so a function can promise disjointness (the borrow crate, view types, reborrow traits). Polonius, as the sidebar will show, is neither: it sharpens reasoning inside a body and leaves the signature contract untouched.

Eleven years of folklore

The folklore came first. This problem was known before Rust 1.0 shipped in 2015, and production code did not sit around blocked: four workarounds cover essentially every case. Each gets a small runnable cell below, and each is a different way of smuggling disjointness past a signature that cannot spell it.

Workaround one: destructure. If you can see the fields, one pattern splits them: let Graph { nodes, edges } = &mut g; produces two independent &mut bindings, because matching through a mutable reference borrows each named field separately. Zero runtime cost, zero new API, stable since 1.0. The catch: it requires field visibility, and it abandons method syntax entirely.

Workaround 1: destructure

Workaround two: free functions that name their fields

If &mut self is the problem, stop writing self. A free function takes &mut Vec<String> instead of &mut Graph, and now its signature names its footprint, which is the whole game. The call site lends &mut g.nodes and &mut g.edges, two disjoint places the checker verifies exactly, and everything composes again.

This is split_at_mut thinking applied by hand: push the disjointness proof into types the current grammar can already express. The cost is ergonomic, not technical. graph.add_node("D") decays into add_node(&mut g.nodes, "D"), the parameter list grows with every field a helper touches, and the API drifts toward "bag of functions". Real codebases, including rustc itself in places, pay that price on purpose.

Free functions name their fields

Workaround three: mem::take, the ownership dodge

The first two workarounds relocate a borrow; this one eliminates it. std::mem::take(&mut self.nodes) moves the vector out of the struct and leaves a fresh empty one behind. The loop below then iterates a local, not a field, so no borrow of self spans the loop body, and &self or &mut self methods become freely callable mid-iteration.

The costs are specific. The field must implement Default, or you upgrade to mem::replace with a dummy value. The move is cheap for a Vec: three pointer-sized words, zero element copies. But between the take and the put-back, self.nodes is empty, and anything that observes self in that window (a callee, a panic unwind, a Drop impl) sees the lie. Skip the put-back and the compiler says nothing; the data is simply gone. This is the workaround that works through any API, and the one most likely to bite you six months later.

Take it out, put it back

Workaround four: the inline-closure trick

Since edition 2021, closures capture individual fields, not whole structs. || g.nodes.push(..) captures exactly g.nodes; a sibling closure capturing g.edges does not conflict with it. So when you want two methods with disjoint footprints and cannot have them, write two closures. The capture set plays the role the signature cannot: a machine-checked record of exactly which fields each body touches, computed from the body. That is precisely the analysis the checker refuses to run across function boundaries, and closures get it because a closure is not a boundary; it lives inside the function being checked.

The limits follow directly: a closure pair is not an exportable API, storing the closures in a struct recreates the original problem one level up, and named methods are easier to discover than locals. As a local pressure valve, though, it is surprisingly effective.

Two closures, two captures

The scorecard, and the common thread

Four workarounds, one table:

WorkaroundWhere the split livesWorks through APIs?Main cost
DestructuringCall siteNo, needs field accessLoses method syntax
Free functionsSignature, via field typesYes, if you own the APIPlumbing; bag-of-functions drift
mem::takeNowhere; ownership, not borrowingYes, any APIDefault bound; empty-field window
ClosuresCapture set, one body onlyNoLocal only; not storable

Every row shares one property: none lets a method on your type declare "I only touch nodes". Each either moves the split to the call site, where the checker already reasons precisely, or sidesteps borrowing altogether. The signature layer, the one place the fix belongs, stays mute. Which returns us to the main question: what would it take for a signature to say the sentence?

A crate that ships the future syntax

The borrow crate (v2, October 2025) does something delightful: it lets you write a partial borrow in the type today, on stable Rust, with no nightly flag. You add #[derive(borrow::Partial)] to the struct, and then a function can ask for exactly the fields it needs:

  • p!(&<mut nodes> Graph) means "a Graph whose nodes I may mutate, and nothing else."
  • p!(&<mut edges> Graph) means the same for edges.

Two functions with those two signatures can run against the same graph without a fight, because each one's signature now names its footprint, exactly what split_at_mut does. The Graph in this notebook's shared source carries the derive. Watch the two functions coexist.

(The kicker: p!(&<mut nodes> Graph) is roughly the shape the language feature might eventually take. This is a crate letting you rehearse a syntax that is still being designed.)

Partial borrows, side by side

But were they ever alive at the same time?

Fair challenge. In the cell above, add_node finished completely before add_edge started, so a skeptic could argue the two borrows never actually overlapped. So let us make them overlap. The borrow crate can split one borrow into two disjoint, simultaneously live mutable views, the struct-field analogue of split_at_mut. Below, nodes and rest are both alive on the same lines, both mutable, pointed at different fields. No E0499.

Two live &mut borrows, one struct

What the language is actually building

The crate is a rehearsal. Two language-side efforts are in flight.

Maximally minimal view types (Niko Matsakis, March 21, 2026). A deliberately stripped-down take on letting a function's signature name the fields it touches, something like fn add_node(&mut self.nodes). The "maximally minimal" framing is the whole point: earlier view-type proposals tried to do everything and stalled, so this one tries to do the least that still solves the disjoint-borrow case, on the theory that a small feature that ships beats a general one that does not.

Reborrow and CoerceShared traits (2026 project goal). A different attack: give the language first-class Reborrow and CoerceShared traits so user types can opt into the same reborrowing magic that &mut T gets for free. The 2025H2 update reported a working nightly implementation for types with a single lifetime and trivial layout, now iterating on feedback. It is the furthest along in actual compiler code.

One disambiguation. Partial borrows are specifically distinct from field projections, the other "reach into a struct" feature people are excited about. Projection is about turning a Pin<&mut Wrapper> into a Pin<&mut Field>, projecting a wrapper through to one of its fields. Partial borrows are about holding several disjoint field borrows at once. They are adjacent problems, and neither subsumes the other.

What view types look like on paper

None of this compiles anywhere today, which is why it lives in a fence instead of a cell. But the sketch has a definite shape, worth reading next to the crate syntax above. A method names the fields it touches, directly in the type of self:

// SKETCH ONLY: not valid Rust in July 2026, on any channel.
impl Graph {
    fn add_node(&mut {nodes} self, label: String) {
        self.nodes.push(label);
    }
    fn add_edge(&mut {edges} self, from: usize, to: usize) {
        self.edges.push((from, to));
    }
}

let mut g = sample_graph();
g.add_node("D".into()); // borrows only g.nodes
g.add_edge(2, 3);       // borrows only g.edges: no conflict possible

Read &mut {nodes} self as "a mutable view of self, restricted to nodes". Call sites then check exactly like the destructuring cell earlier: two borrows of two provably disjoint places. The Act 1 wall does not get a better error message; it stops being an error at all.

The maximally minimal part is everything the sketch refuses to do, and each refusal routes around a hazard that stalled an earlier, more general proposal:

  • Fields only, not arbitrary places. You can name nodes, not nodes[0] or a computed projection. Arbitrary place expressions in types are a research program; field names are a lookup.
  • No abstraction over field sets. No generics over "some set of fields", no view aliases, no view bounds. First-class views need a theory of view subtyping, and that theory is what the earlier proposals could never finish.
  • Privacy stays intact. A public signature naming private fields would leak implementation detail, so the conservative reading keeps views intra-crate: public API keeps saying &mut self, private helpers get exact footprints. That concedes the cross-crate problem, for now, on purpose.

The bet is explicit: a feature covering most of the disjoint-borrow pain that ships in one release beats a complete theory of views that ships never.

Sidebar: Polonius, the other modernization

Partial borrows are about teaching the checker to see disjoint fields. Polonius is about teaching it to see time more precisely. It is a from-scratch reformulation of the borrow checker that is location-sensitive: it tracks which loans are live at each point in the control-flow graph, instead of approximating with the coarser regions today's NLL checker uses.

The cleanest way to feel the difference is the canonical case NLL gets wrong, known affectionately as "NLL problem case 3." Return a borrow on one branch, mutate on the other. You and I can both see the borrow from the early return cannot possibly be alive on the line that mutates, because the early return already left the function. Today's checker cannot see it. Watch.

NLL problem case 3 (does not compile)

What Polonius sees that NLL cannot

The error lands on map.insert, and the reason it gives is the tell:

returning this value requires that *map is borrowed for '1

Because get can return a reference that escapes through return value, today's checker conservatively assumes the borrow from get lasts for the entire rest of the function, including the else path where you know it is already gone. So the later map.insert looks like a conflict and you get E0502. Polonius, being location-sensitive, sees that on the else path no loan from get is live, and accepts this code unchanged. Same source, no rewrite, no entry-API gymnastics.

On February 28, 2026, rust#150551 landed and the driver called Polonius "stabilizable". The remaining cost is performance: the location-sensitive analysis is more expensive, and the team has signaled it is willing to eat a 10 to 20% borrow-check hit in the typical case (worst case near 60% on a pathological function) to make code like the cell above just work. That is a remarkable trade to put on the table, and a good measure of how much this one paper cut has cost the ecosystem in aggregate.

What Polonius fixes, and what it deliberately does not

Keep two axes straight, because conflating them is the most common mistake in borrow-checker discourse.

Polonius sharpens time: which loans are live at which point of one function body. Its wins are flow-sensitive: the get-or-insert above, borrows returned from some branches and not others, loops carrying a loan only on certain iterations. In each, the body contains the proof of safety and NLL's coarser regions discard it; Polonius keeps it. Same source, zero annotations.

Polonius does not touch space: which fields a callee may reach. It is still an intraprocedural analysis reading the same signatures. Run the opening wall under -Z polonius and you get the same errors in the same places:

// Still rejected, Polonius or not:
let ns = g.nodes_mut(); // signature: borrows ALL of g
let es = g.edges_mut(); // E0499, exactly as before
ns.push("C".into());

nodes_mut's signature still claims the whole struct, and no amount of location-sensitivity changes what a signature says. The ledger: Polonius fixes real daily friction, ships with zero new syntax, and does nothing for partial borrows. The efforts are complementary: Polonius upgrades the checker's reasoning inside a function; view types and reborrow traits upgrade what functions can say to each other. You want both.

Where the discussion stands, July 2026

A calibration snapshot, so you can tell shipped from aspirational:

  • View types: pre-RFC. The max-min post is a design sketch by a lang-team member, not an accepted RFC. It grew out of the long-running notes-on-partial-borrows thread; the open questions are surface syntax (the {nodes} notation is a placeholder), public-API visibility given the privacy tension, and traits. Nothing is implemented; measure progress in posts, not PRs.
  • Reborrow traits: nightly, narrow. A funded 2026 project goal with a working nightly implementation for the constrained case: one lifetime, trivial layout. Current work is widening the supported types and settling the trait signatures. The only language effort with code you can run tonight, behind a feature gate.
  • Polonius: on the stabilization runway. The February 28, 2026 landing of rust#150551 moved the conversation from design to stabilization criteria. The gate is the compile-time budget: the 10 to 20% typical-case overhead quoted earlier, and how much engineering can claw back before the default flips.
  • The borrow crate: shipped. v2 since October 2025, stable Rust, no flags, as the cells above demonstrated live. Quietly the strongest argument for building the language feature: a proc macro faking a type-system extension well enough for production is demand measured in downloads rather than forum posts.

The pattern is the usual Rust one: the checker's theory advances on one track, the signature grammar on another, and the ecosystem paves the cowpath years ahead of both.

Which one lands first? (a friendly wager)

Three answers, one paper cut. Here is my bet, offered as directional rather than precise.

Polonius ships first, and it is not that close. It is a -Z flag today, it has a landed PR its own driver calls "stabilizable," and it needs no new surface syntax, which is where language features go to spend two extra years bikeshedding. Second, Reborrow traits, because there is real nightly code and the single-lifetime case already works; traits are a smaller ask than new borrow syntax. Third, and I say this with affection, view types, the feature with the best story and the longest road, because "name a subset of your fields in a signature" is exactly the kind of small-looking syntax that turns out to touch everything.

And a fourth thing quietly wins in the meantime: the borrow crate is usable this afternoon. That is the recurring lesson of the modern Rust ecosystem. When the language takes the scenic route, someone ships a crate that fakes the destination well enough to use in production, and by the time the real feature arrives half of us have forgotten we were ever waiting.

Primary sources, if you want them: view types, the Reborrow traits goal, the Polonius goal, and the internals thread on partial borrows that started much of it.