How closures capture variables

Rust closures capture variables from their defining scope by borrowing or moving them into their internal environment.

The closure backpack

You write a closure to handle a network response. You pass it to an async runtime. The compiler rejects you with a lifetime error. You add the move keyword. It compiles. You assume move makes the closure "move" the data, but the real question is why the compiler didn't just borrow the variable like it usually does.

Closures in Rust capture variables from their surrounding scope. They don't look up variables at call time. They pack what they need when they are defined. The compiler decides whether to borrow immutably, borrow mutably, or take ownership based on how the closure uses the data. You can override this decision with move, but understanding the default behavior saves you from fighting the borrow checker.

How capture works

A closure is a function that carries a backpack. When you define the closure, it scans its body for variables from the outer scope. For each variable, the compiler picks the cheapest capture mode that satisfies the code inside the closure.

The compiler has three choices:

  • Immutable borrow (&T): The closure only reads the variable. It borrows it. The original variable remains usable.
  • Mutable borrow (&mut T): The closure mutates the variable. It takes a mutable borrow. The original variable is locked while the closure exists.
  • Move (T): The closure consumes the variable. It takes ownership. The original variable is unusable after the closure is defined.

The compiler prefers borrowing. It only moves if the closure body requires ownership, such as calling a method that takes self by value.

fn main() {
    let name = String::from("Rust");
    
    // Closure only reads name. Compiler infers immutable borrow.
    // name remains usable after this line.
    let greet = || println!("Hello, {}", name);
    
    greet();
    
    // name is still valid. The closure borrowed it, it didn't take it.
    println!("Name is {}", name);
}

The compiler packs the backpack at definition time. Shadowing the variable later leaves the closure holding the old value.

Inference and the three traits

The capture mode determines which trait the closure implements. Rust has three closure traits:

  • Fn: The closure can be called multiple times and captures by immutable borrow.
  • FnMut: The closure can be called multiple times and captures by mutable borrow.
  • FnOnce: The closure can be called once and captures by move.

The compiler infers the trait from the capture mode. If the closure mutates a captured variable, it implements FnMut. If it moves a captured variable, it implements FnOnce. If it only reads, it implements Fn.

fn main() {
    let mut count = 0;
    
    // Closure mutates count. Compiler infers mutable borrow.
    // This closure implements FnMut, not Fn.
    let increment = || {
        count += 1;
    };
    
    increment();
    increment();
    
    println!("Count: {}", count);
}

If you try to use count mutably while the closure exists, the compiler rejects you with E0502 (cannot borrow as mutable because it is also borrowed as immutable). The mutable borrow held by the closure blocks other mutable access.

The move keyword

The move keyword forces the closure to take ownership of all captured variables. It overrides the compiler's inference. Even if the closure only reads the data, move takes ownership.

fn main() {
    let data = vec![1, 2, 3];
    
    // move forces ownership. data is moved into the closure.
    let process = move || {
        println!("Processing {:?}", data);
    };
    
    process();
    
    // data is moved. This line would fail with E0382 (use of moved value).
    // println!("{:?}", data);
}

Use move when the closure must outlive the scope where it is defined. Threads, async tasks, and callbacks often store closures and run them later. The compiler cannot guarantee that borrowed data will still be alive, so it requires ownership.

use std::thread;

fn main() {
    let config = String::from("production");
    
    // thread::spawn takes ownership of the closure.
    // The thread might run after main returns, so the closure must own config.
    let handle = thread::spawn(move || {
        println!("Running in {}", config);
    });
    
    handle.join().unwrap();
}

Without move, the compiler rejects this code because the closure captures a borrow of config, but the thread could outlive config. Adding move transfers ownership of config to the closure, satisfying the lifetime requirements.

Convention: move on references

Adding move to a closure that captures a reference moves the reference, not the data. This is a subtle but important distinction.

fn main() {
    let data = String::from("shared");
    
    // move captures the reference &data, not the String.
    // The String stays in main's stack. The closure owns the reference.
    let read = move || println!("{}", data);
    
    read();
    
    // data is still usable. The closure moved the reference, not the String.
    println!("Data: {}", data);
}

This pattern appears when you want to clarify that the closure takes the reference explicitly. It also matters for trait bounds. A closure that captures &String might implement Send if the reference is Send, whereas a closure that captures String always implements Send if String is Send. The community convention is to use move when passing closures to threads or async runtimes, even if the captures are references, to make the ownership transfer explicit.

Rust versus JavaScript: the binding trap

Developers coming from JavaScript or Python often expect closures to capture variable bindings. In JavaScript, a closure captures the variable itself, so reassigning the variable updates what the closure sees.

let x = 5;
let c = () => console.log(x);
x = 10;
c(); // Prints 10

Rust works differently. Closures capture values or references, not bindings. Reassigning a variable in Rust creates a new variable or moves the value. The closure still holds the old capture.

fn main() {
    let x = 5;
    
    // Closure captures the value 5.
    let c = || println!("{}", x);
    
    // This shadows x with a new value.
    // The closure still holds the old 5.
    let x = 10;
    
    c(); // Prints 5
}

If x is a mutable reference, the closure sees mutations through the reference. Reassignment still breaks the link.

fn main() {
    let mut x = 5;
    
    // Closure captures a mutable reference to x.
    let c = || println!("{}", x);
    
    // Mutating x through the reference is visible to the closure.
    x = 10;
    
    c(); // Prints 10
    
    // Reassigning x creates a new variable.
    // The closure still points to the old slot.
    let mut x = 20;
    
    c(); // Prints 10, not 20
}

Closures capture at definition. Shadowing the variable later changes nothing for the closure.

Pitfalls and compiler errors

Closures introduce borrow conflicts and lifetime issues. The compiler catches these at compile time, but the errors can be confusing if you don't understand capture modes.

E0382: use of moved value

This error occurs when you try to use a variable after a move closure captures it.

fn main() {
    let data = String::from("hello");
    let c = move || println!("{}", data);
    
    // Error E0382: use of moved value `data`
    // println!("{}", data);
}

The fix is to avoid using the variable after the closure, or to remove move if the closure doesn't need ownership.

E0502: borrow conflict

This error occurs when a closure captures a mutable borrow, and you try to use the variable immutably while the closure exists.

fn main() {
    let mut count = 0;
    
    // Closure captures mutable borrow.
    let increment = || { count += 1; };
    
    // Error E0502: cannot borrow `count` as immutable because it is also borrowed as mutable
    // println!("{}", count);
    
    increment();
}

The fix is to use the variable before defining the closure, or to clone the data if the closure doesn't need to mutate the original.

E0277: trait bound not satisfied

This error occurs when a closure captures a type that doesn't satisfy a trait bound, such as Send for threads.

use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new(String::from("shared"));
    
    // Error E0277: the trait `Send` is not implemented for `Rc<String>`
    // thread::spawn(move || {
    //     println!("{}", data);
    // });
}

Rc is not thread-safe. The fix is to use Arc instead, which is designed for shared ownership across threads.

Don't guess the capture mode. Let the compiler infer it, and add move only when the scope boundary demands ownership.

When to use default capture versus move

Use default capture when the closure is called within the same scope and you need to keep using the variables afterwards. The compiler borrows the data, leaving the original variables available.

Use move when passing the closure to a function that stores it or runs it in another thread. The closure must own its captures because the function might outlive the scope.

Use move when the compiler complains about lifetimes and the closure needs to own the data to satisfy the trait bounds. This often happens with async runtimes and callback systems.

Use move when you want to transfer ownership explicitly to avoid borrow conflicts. If a closure captures a mutable borrow, it locks the variable. Moving the data into the closure releases the borrow on the original variable, though the variable becomes unusable.

Use move on a reference to clarify intent that the closure takes the reference, not the data. This is rare but useful for documenting ownership semantics in complex code.

Trust the inference. The compiler picks the cheapest capture that works. Override it only when the scope or trait requirements force your hand.

Where to go next