How do lifetimes interact with closures

Lifetimes ensure references captured by closures remain valid, often requiring explicit annotation or the move keyword to manage ownership.

The closure that outlives its data

You are building a text processor. You load a large configuration string into memory. You write a closure to parse that string and extract keywords. You pass the closure to a background thread so the parsing doesn't block the main loop. The compiler stops you with a lifetime error. The closure borrows the configuration string, but the string lives only in the main function. The thread might run after the main function returns. If that happens, the closure holds a reference to memory that has been freed. Rust refuses to compile the code. This is the core tension between closures and lifetimes.

Closures capture variables from their environment. When they capture references, the closure's lifetime becomes tied to the lifetime of the borrowed data. The compiler enforces this tie strictly. If the closure can outlive the data, the code is rejected. You resolve this by changing how the closure captures the data, usually by transferring ownership instead of borrowing.

Closures are backpacks with bookmarks

A closure is a function that packs variables from its surrounding scope. Think of a closure as a backpack. When you define a closure, it looks at the variables it uses and packs them into the bag.

If the variable is a reference, the backpack holds a bookmark pointing to the data. The bookmark is cheap to copy, but it is useless if the book gets shredded. The lifetime rule for closures is just a formalization of this intuition: the backpack cannot outlive the book it points to. If the book is dropped, the bookmark becomes a dangling pointer. Rust prevents this by tying the closure's lifetime to the reference's lifetime.

If the variable is owned data, the backpack holds the actual book. The backpack now owns the data. The data lives as long as the backpack lives. This breaks the dependency on the original scope. The closure can travel anywhere, and the data travels with it.

The default capture: borrowing by reference

By default, Rust closures capture variables by reference. This is the most efficient strategy. It avoids copying data and allows the original scope to keep using the variable. The compiler infers the reference type automatically. If the closure only reads the variable, it captures an immutable reference. If the closure mutates the variable, it captures a mutable reference.

fn main() {
    let message = String::from("Hello from closure");
    
    // The closure captures `message` by immutable reference.
    // It does not copy the String. It holds a &String.
    let greet = || println!("{}", message);
    
    // The closure runs while `message` is still alive.
    greet();
    
    // `message` is still usable here because the closure only borrowed it.
    println!("Original: {}", message);
}

The compiler generates a unique type for greet. That type includes the lifetime of the reference to message. You cannot write this type by hand. It is an opaque type known only to the compiler. The type effectively says "this closure holds a reference valid for some lifetime 'a". The compiler tracks 'a and ensures greet is never used after message is dropped.

Trust the borrow checker here. The default capture is safe and fast. You only need to change the capture strategy when the lifetime constraints block your design.

When the scope ends, the reference dies

Problems arise when you try to store a closure or pass it to a function that might use it later. The closure's type carries the lifetime of the captured reference. If the function expects a closure that can live longer than the current scope, the compiler rejects the borrow.

fn run_later<F: Fn()>(f: F) {
    // This function might call `f` at any time.
    // It expects `f` to be valid for as long as `run_later` needs it.
    f();
}

fn main() {
    let data = String::from("temporary");
    
    // This closure captures `data` by reference.
    // Its lifetime is tied to `data`.
    let task = || println!("{}", data);
    
    // Error: `data` is borrowed by `task`, but `run_later` might
    // hold `task` longer than `data` lives.
    // run_later(task);
}

The compiler rejects this with E0597 (borrowed value does not live long enough). The error tells you that the reference inside the closure does not survive long enough for the usage pattern. The closure is a backpack with a bookmark, and the book is about to be dropped.

This error is common when working with asynchronous runtimes, thread pools, or any API that stores callbacks. The API needs to guarantee that the closure can be called later. A borrowed closure cannot provide that guarantee.

The move keyword breaks the chain

The move keyword changes how a closure captures variables. It forces the closure to take ownership of every variable it captures. The variables are moved into the closure's environment. The closure no longer holds references. It holds the data itself.

fn main() {
    let data = String::from("owned by closure");
    
    // The `move` keyword forces ownership transfer.
    // `data` is moved into the closure.
    let task = move || println!("{}", data);
    
    // `data` is no longer accessible here.
    // println!("{}", data); // Error: E0382 (use of moved value)
    
    // The closure owns `data`. It can live as long as the closure lives.
    task();
}

With move, the closure's lifetime is no longer tied to the original scope. The closure owns the data. You can store the closure in a struct, pass it to a thread, or keep it around indefinitely. The data stays alive because the closure holds it.

The move keyword is the standard solution for lifetime errors in closures. It shifts the burden from the borrow checker to the heap. The data is allocated on the heap and moved into the closure. This has a small allocation cost, but it buys you flexibility.

Use move when the closure must outlive the current scope. Use move when you are passing a closure to a thread. Use move when you are storing a closure in a struct field. The keyword is your tool for decoupling the closure from its creation site.

Static closures and threads

A closure that captures no variables has the 'static lifetime. It does not depend on any external data. It can live forever. This is crucial for threading. When you spawn a thread, the thread might outlive the main function. The closure passed to the thread must be 'static or owned by the thread.

fn main() {
    // This closure captures nothing.
    // It has the 'static lifetime.
    let static_task = || println!("I live forever!");
    
    // You can pass this to a thread safely.
    // The thread owns the closure.
    std::thread::spawn(move || {
        static_task();
    });
}

If the closure captures variables, you must use move to transfer ownership into the thread. The thread then owns the data. The data lives as long as the thread lives. If the thread panics, the data is dropped. If the thread finishes, the data is dropped. The ownership chain is clear.

Convention dictates that closures passed to std::thread::spawn almost always use move. The thread is a separate execution context. It cannot borrow from the main thread's stack. Moving data into the thread is the only safe way to share state.

Pitfalls and compiler errors

Closures and lifetimes interact in subtle ways. A few patterns cause frequent friction.

Mutable captures impose strict borrowing rules. If a closure mutates a captured variable, it captures a mutable reference. This means you cannot use the variable while the closure exists. The mutable borrow is active for the entire lifetime of the closure.

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

If you need to use the variable and the closure simultaneously, you cannot capture by mutable reference. You need interior mutability. Wrap the data in a Cell or RefCell and move that wrapper into the closure. The closure captures the wrapper by value. It can mutate the contents without holding a mutable reference to the outer variable.

Multiple references create intersection lifetimes. If a closure captures references from two different variables, the closure's lifetime is the intersection of both lifetimes. The closure dies when the shorter-lived variable drops.

fn main() {
    let a = String::from("A");
    let b = String::from("B");
    
    // Closure captures both `a` and `b`.
    let combine = || println!("{} {}", a, b);
    
    // The closure's lifetime is tied to both `a` and `b`.
    // If `a` drops, the closure becomes invalid, even if `b` is still alive.
    // drop(a);
    // combine(); // Error!
}

The compiler tracks the intersection automatically. You cannot extend the closure's lifetime beyond the shortest capture. If you need the closure to survive the drop of one variable, you must move that variable into the closure.

Convention aside: When you see a closure capturing many variables, check if move simplifies the code. A move closure with a few owned fields is often easier to reason about than a closure with complex borrow intersections. The community prefers move closures for callbacks and stored handlers because they isolate the closure from the outer scope. The closure becomes a self-contained unit.

Decision: capture by reference or move

Choosing between default capture and move depends on the closure's lifetime requirements. Use the parallel structure below to decide.

Use a standard closure || when the closure runs immediately or within the same scope as the captured data. The compiler handles the borrowing automatically, and you get zero-cost references. This is the default for iterators, map operations, and short-lived callbacks.

Use move || when the closure needs to outlive the current scope, such as when spawning a thread or storing the closure in a struct. The move keyword forces the closure to take ownership of the captured variables, decoupling the closure's lifetime from the original scope. This is the standard pattern for asynchronous handlers and background tasks.

Use move || when you want to isolate the closure from the outer scope's mutability. Moving data into the closure prevents accidental aliasing. The closure owns the data, so it can mutate it without conflicting with the outer scope. This is useful for state machines and event loops.

Use interior mutability like Cell or RefCell when you need to share a variable between a closure and the outer scope, but the closure needs to mutate it. Wrap the data in the cell, move the cell into the closure, and mutate through the cell. This allows the closure to update the data without holding a mutable reference.

Avoid explicit lifetime annotations on closure types unless you are writing a generic function that accepts a closure and needs to constrain the captured references. The compiler usually infers these bounds correctly. Explicit annotations add noise and rarely improve clarity.

When in doubt, add move. It is the fastest way to resolve lifetime errors in closures. It changes the semantics by transferring ownership, but it is often the intended behavior for stored closures. If move causes a compile error because a type does not implement Copy or Clone, that is a signal to review your data ownership. You might need to clone the data or restructure the scope.

Where to go next