Skip to content
← All articles

Disjoint field capture: what changed in edition 2021

The most-used Rust feature nobody knows the name of. Since RFC 2229 a closure captures precise places, not whole variables — which silently deleted a whole category of borrow errors, and quietly changed when your values drop.

Here is a piece of code that is completely unremarkable today and was a compile error in 2020.

struct Report {
    rows: Vec<String>,
    total: i64,
}

fn main() {
    let mut r = Report { rows: vec!["a".into()], total: 0 };

    let mut push = |s: &str| r.rows.push(s.to_string());
    println!("total so far: {}", r.total);
    push("b");

    println!("{:?} {}", r.rows, r.total);
}

In edition 2018 that does not compile. Here is rustc --edition 2018 on exactly the code above:

error[E0502]: cannot borrow `r.total` as immutable because it is also borrowed as mutable
 --> rep.rs:5:34
  |
4 |     let mut push = |s: &str| r.rows.push(s.to_string());
  |                    --------- ------ first borrow occurs due to use of `r` in closure
  |                    |
  |                    mutable borrow occurs here
5 |     println!("total so far: {}", r.total);
  |                                  ^^^^^^^ immutable borrow occurs here
6 |     push("b");
  |     ---- mutable borrow later used here

Read the label on the first span: “first borrow occurs due to use of r in closure”. Not r.rowsr. The closure captured the whole struct, mutably, so reading r.total between the closure’s creation and its last use is E0502. The standard workaround, which you will still find all over older codebases and every blog post from that era, was to hoist the field out by hand:

let rows = &mut r.rows;                       // the "pre-capture" dance
let mut push = |s: &str| rows.push(s.to_string());

In edition 2021 and later it just compiles. The closure captures r.rows, not r. Nobody told you, because there was nothing to learn: a class of errors stopped happening.

This is disjoint closure capture, from RFC 2229. It shipped as one of the four flagship changes of edition 2021, alongside the new IntoIterator for arrays, the disjoint panic! formatting, and TryFrom/TryInto in the prelude. Of the four it is by far the most consequential and by far the least discussed, because it is the only one whose whole job is to not produce an error.

It matters to you now, in this track, for three reasons. It is why closures over self behave in ways older tutorials say they cannot. It is the reason a move closure sometimes moves less than you expected. And it changes when your values drop, which is the one place it can bite rather than help.

The old model: capture a variable

Pre-2021, closure capture analysis worked on variables. If a closure body mentioned r.rows, the compiler recorded “this closure captures r“, picked a mode (shared, mutable, or by value), and that was that. The granularity was the whole binding.

That was simple to specify and simple to implement, and it was wrong in the way that a too-coarse approximation is always wrong: it reported conflicts that did not exist. Two closures touching two different fields of the same struct were, to the analysis, two closures fighting over the same variable.

💡The struct fields r.rows and r.total are obviously distinct places, and the borrow checker has always known that fields are disjoint — &mut r.rows and &r.total coexist happily outside a closure. So why did the closure version fail? click to reveal

Because the borrow checker was never the problem. Capture analysis ran first and produced a coarser answer, and the borrow checker faithfully checked what capture analysis had told it.

The borrow checker has understood disjoint fields since forever: let a = &mut r.rows; let b = &r.total; has always been fine, because field projections resolve statically and r.rows and r.total are distinct nodes in the loan tree. Item 3.1 covers that rule.

What failed was upstream. Closure capture was specified in terms of variables, so by the time the borrow checker looked at the closure, the closure’s recorded capture list said &mut r — an exclusive loan of the entire struct. From there, rejecting r.total was not merely correct, it was mandatory: an exclusive borrow of a place forbids any access to that place, including a read.

RFC 2229 did not change the borrow checker. It changed what capture analysis hands it. Same rule, finer input, fewer false conflicts.

The new model: capture a place

Since edition 2021, a closure captures places. A place is the compiler’s word for “a location a value lives in” — r, r.rows, t.0.1, *b. It is what you can write on the left of an =.

So the analysis now walks the closure body, collects the exact place expressions it uses, and captures those. The closure above ends up with a single capture: &mut r.rows. r.total is untouched and stays readable.

The immediate practical consequence, and the one you will feel in this track, shows up on methods:

struct Index {
    seen: std::collections::BTreeSet<i64>,
    out: Vec<i64>,
}

impl Index {
    fn absorb(&mut self, xs: &[i64]) {
        // The iterator holds `&self.seen`; the closure captures `self.out`
        // mutably. Two different places, so no conflict. Compiles today.
        self.seen.iter().for_each(|&s| self.out.push(s));

        for &x in xs {
            self.seen.insert(x);
        }
    }
}

That for_each line is the whole feature in one statement. self.seen.iter() holds a shared loan of self.seen across the call, and the closure needs &mut self.out. Under the old model both were loans of self, and one of them had to go — which is why the pre-2021 form was

let out = &mut self.out;                       // the "pre-capture" dance, again
self.seen.iter().for_each(|&s| out.push(s));

You can now write the closure form directly in most cases where you previously had to hoist. That is the let vec = &mut self.vec; dance dying out — and the reason a lot of Rust from 2019 looks needlessly contorted when you read it today.

The rules, precisely

Four of them are load-bearing. They are worth knowing exactly, because each one produces a distinctive surprise when you meet it without knowing it.

1. Paths truncate at a shared-reference dereference. If the path to a place goes through a &T, capture stops there and takes the reference itself. You cannot capture “the field behind a shared reference” in isolation, because you do not own the thing the reference points at; the reference is the smallest unit you have. In a move closure the truncation is stricter still: the path stops at any dereference of a reference, shared or mutable.

2. Shared ancestors get the strongest mode. If a closure uses t.0 immutably and t.1 mutably, those are two separate captures with two separate modes. But if it uses t.0 and also t itself, the analysis captures t — with whichever of the two modes is stronger.

3. Wildcard patterns do not capture at all. let _ = x; inside a closure reads nothing and captures nothing. This is not an optimisation, it is the specified behaviour, and it is exactly why the migration idiom in the next section looks like dead code.

4. Copy values used by value in a move closure are still captured by immutable borrow. This one surprises people. move means “take by value”, but for a Copy type, a shared borrow that gets copied out is indistinguishable from a copy — so the analysis is free to record the weaker mode, and does.

💡A closure body contains let _ = &x; and nothing else that mentions x. Rule 3 says let _ = x; captures nothing. Does let _ = &x; also capture nothing? click to reveal

No — and the difference between those two lines is the entire point of the idiom.

let _ = x; matches x against the wildcard pattern. A wildcard binds nothing and reads nothing, so under RFC 2229 there is no place to capture, and the closure does not capture x.

let _ = &x; is different: &x is an expression that takes a reference to the place x, and the place being referenced is x itself, not any field of it. So the closure captures the whole of x, by shared borrow. That is precisely the pre-2021 behaviour you are trying to restore.

This is why let _ = &x; — not let _ = x; — is the migration idiom cargo fix --edition inserts when it detects that a change in capture granularity would change drop order. It is also why it is so easily deleted: it looks like a statement that does nothing, it triggers no lint, and the reason it exists is invisible at the call site. If you meet one in a codebase, leave it alone and go looking for a Drop impl.

The part that can bite: drop timing

Everything so far has been “an error stopped happening”. Here is the one observable behaviour change, and it is the reason the edition guide flags this feature at all.

Captured values are dropped when the closure is dropped. Under the old model, a move closure that mentioned t.0 captured all of t, so t.1 was dropped with the closure. Under the new model it captures only t.0, so t.1 is dropped at the end of the enclosing scope instead — a different point in the program.

struct Noisy(&'static str);
impl Noisy { fn ping(&self) { println!("ping {}", self.0); } }
impl Drop for Noisy {
    fn drop(&mut self) { println!("drop {}", self.0); }
}

fn main() {
    let t = (Noisy("first"), Noisy("second"));
    {
        let c = move || t.0.ping();
        c();
    }                                  // <- `c` is dropped here
    println!("closure gone");
}

Run it under both editions. This is real output, not a thought experiment:

--edition 2024          --edition 2018
ping first              ping first
drop first              drop first
closure gone            drop second
drop second             closure gone

Under 2018 the closure captured all of t, so both halves died with the closure at the closing brace. Under 2021 and later it captured only t.0; t.1 is still owned by t and drops at the end of main. Two lines swapped, no diagnostic, no warning.

For code whose Drop impl only frees memory, nobody notices. For code whose Drop impl releases a lock, closes a file, commits a transaction, or decrements a refcount that something else is watching, the difference is a real behaviour change — which is exactly why this could only ship behind an edition boundary, and why cargo fix --edition inserts let _ = &t; when it cannot prove the change is harmless.

A second, subtler consequence: auto traits are computed from the captures. A closure is Send if everything it captures is Send. Capture less, and you can gain Send you did not have before — which is a pure improvement. But the reverse is also expressible: if the field you now capture is the non-Send one and the field you no longer capture was the Send one, a closure that used to be Send-by-accident is not any more. Rare, but real, and the diagnostic will point at a thread::spawn far from the closure.

What it still cannot do

Do not overstate this feature. The precision has hard edges, and knowing them saves you an hour of confusion.

  • Arrays and slices are never partially captured. arr[0] captures arr. Indexing is a runtime value, and the analysis is purely static — this is the same boundary that makes &mut v[0] and &mut v[1] conflict outside closures.
  • Capture stops before a raw-pointer dereference. *p where p: *const T truncates the path at p.
  • Capture stops before a union field access, because reading a union field is unsafe and the analysis will not silently pick one.
  • It changes nothing about the Fn/FnMut/FnOnce decision. That is still “what does the body do to the captures”, per item 9.2.
💡A move closure body uses self.buf.len() where self: &mut Something — a mutable reference. What does the closure capture: self.buf, *self, or self? click to reveal

It captures self — the reference itself, by value.

Rule 1’s strict form is what decides it. In a move closure, the capture path is truncated at any dereference of a reference. The place self.buf is really (*self).buf, and that path contains a deref of self, so the analysis stops there and captures self.

In a non-move closure the truncation rule is weaker — it only stops at shared-reference derefs — so a plain || self.buf.len() can capture the finer place (*self).buf by shared borrow, leaving self.other_field free for a &mut. That asymmetry is the single most common reason adding move to a closure produces a borrow error that was not there before.

The practical read: if you add move and suddenly get a conflict, you did not change the body — you coarsened the capture.

What to carry forward

  1. Closures capture places, not variables — since edition 2021, and this crate compiles edition 2024, so it is simply the truth here.
  2. Fewer false conflicts, and the let field = &mut self.field; hoist is usually unnecessary now. If you see it in old code, that is what it was for.
  3. The one thing that genuinely changed at runtime is drop timing, because captured fields can now drop separately from their parent.
  4. let _ = &x; is the “capture the whole thing anyway” idiom. It looks like dead code. It is not. Do not clean it up.
  5. The precision stops at arrays, raw pointers, unions, and — in move closures — at every reference deref.