The backpack and the bookmark
You write a closure to process some data. You pass that closure to a function. The function stores the closure for later. You run the program. The compiler rejects the code with a wall of text about lifetimes.
The closure is holding a reference to a variable. That variable is about to be destroyed the moment the function returns. The closure is a backpack. The reference is a bookmark pointing to a page in a book. You are trying to keep the backpack, but you are about to shred the book. The bookmark points to nothing. Rust refuses to let you pack a bookmark to a book that will vanish.
Closures capture their environment. They can capture variables by moving them into the backpack, or by borrowing references that point back to the original scope. When the closure outlives the scope where the data lives, borrowing fails. You must either move the data into the closure or prove to the compiler that the data lives long enough.
How closures capture
A closure is an anonymous function that remembers values from the scope where it was created. Rust decides how to capture those values based on what the closure does with them and what trait bounds you apply.
If the closure only reads a variable, Rust tries to capture a shared reference. If the closure mutates the variable, Rust tries to capture a mutable reference. If the closure consumes the variable, Rust moves it. This inference keeps code ergonomic. You rarely need to specify capture modes manually. The inference also creates lifetime dependencies. A captured reference ties the closure's lifetime to the variable's lifetime.
When you return a closure from a function, or store it in a struct that outlives the function, those lifetime dependencies become constraints. The compiler checks whether the references inside the closure remain valid for the entire time the closure exists. If the data is local to the function, the references die when the function returns. The closure becomes invalid.
The error that stops you
The most common failure mode is returning a closure that captures a local variable by reference. The compiler detects this immediately.
/// Attempts to return a closure capturing a local reference.
/// This fails because the local variable is dropped when the function returns.
fn make_printer() -> impl Fn() {
let msg = String::from("hello");
// The closure captures `msg` by reference because `msg` is used but not moved.
// The return type `impl Fn()` implies the closure must be valid after this function returns.
|| println!("{}", msg)
}
fn main() {
let printer = make_printer();
printer();
}
The compiler rejects this with E0515 (returned value contains a reference to a local variable). The error message points to msg and explains that msg is borrowed but the closure tries to outlive it. The closure type contains a reference to msg. msg is dropped at the end of make_printer. The closure would hold a dangling reference.
The compiler does not allow dangling references. It enforces this at compile time. You cannot silence this error with unsafe. The fix requires changing how the closure captures the data or changing the lifetime structure of the function.
Move the data. Don't leave it behind.
Fixing it with move
The simplest fix is to force the closure to take ownership of the data. The move keyword tells Rust to move all captured variables into the closure. The data travels inside the backpack. When the closure is called, the data is right there. No references point back to a scope that might vanish.
/// Returns a closure that owns the string data.
/// The `move` keyword forces ownership transfer, so the data lives inside the closure.
fn make_printer() -> impl Fn() {
let msg = String::from("hello");
// `move` forces the closure to capture `msg` by value.
// The String is moved into the closure. The closure owns the data.
move || println!("{}", msg)
}
fn main() {
let printer = make_printer();
// The closure owns the String. It can be called safely.
printer();
// The String is dropped when `printer` goes out of scope.
}
This compiles. The closure type now owns the String. The lifetime of the closure is independent of any external scope. The closure is 'static in terms of captured data. It contains no references to local variables.
The move keyword does not change the trait bound. The closure still implements Fn(). You can call it multiple times. Moving data into a closure does not make it FnOnce. FnOnce means the closure consumes itself on call. move means the closure owns its captures. You can own data and call the closure many times. The distinction matters. move solves the lifetime problem without restricting callability.
Use move when the closure outlives the scope where the data is created. Move the data into the closure so it travels with the backpack.
Borrowing with explicit lifetimes
Sometimes you cannot move the data. The data might be borrowed from elsewhere, or multiple closures might need to share it. In those cases, you must borrow. Borrowing requires lifetime annotations to prove the reference stays valid.
You annotate the function with a lifetime parameter. You tie the closure's lifetime to that parameter. The return type becomes impl Fn() + 'a. The + 'a is a lifetime bound on the trait implementation. It tells the compiler that the closure contains references with lifetime 'a.
/// Creates a closure that borrows a string slice.
/// The lifetime 'a ties the closure to the input data.
fn make_printer<'a>(msg: &'a str) -> impl Fn() + 'a {
// The closure captures `msg` by reference.
// The return type includes `+ 'a`, so the compiler knows the closure
// holds a reference valid for lifetime 'a.
|| println!("{}", msg)
}
fn main() {
let text = String::from("world");
// `text` lives for the entire `main` scope.
// The closure borrows `text`. The lifetime 'a is inferred as the scope of `text`.
let printer = make_printer(&text);
printer();
// `text` is dropped at the end of `main`.
// The closure is also dropped, so no dangling reference.
}
This compiles. The lifetime 'a flows from the input to the output. The compiler checks that any reference passed to make_printer lives at least as long as the closure. If you try to return the closure while the input is dropped, the compiler rejects it.
The syntax impl Fn() + 'a is crucial. Without the + 'a, the return type would be impl Fn(), which implies the closure does not contain references with specific lifetimes, or that the lifetimes are elided. Elision rules for impl Trait returns are strict. The compiler often cannot infer the lifetime of a returned closure if it captures a reference. Explicit annotation removes ambiguity.
Lifetimes are promises. Keep them.
The trait bound trap
The trait bound you choose for the closure affects capture inference. Fn, FnMut, and FnOnce have different requirements. Fn allows the closure to be called multiple times without mutating captured state. FnMut allows mutation. FnOnce allows the closure to be called once and consume captures.
When you use impl Fn(), the compiler prefers to capture by shared reference. This supports multiple calls. If you return impl Fn() and capture a non-Copy type by reference, you hit the lifetime error. Adding move overrides the preference. The closure moves the data. The closure still implements Fn() because the data is owned, not consumed.
When you use impl FnOnce(), the compiler allows moving captures even without move. FnOnce permits consumption. The closure can take ownership of variables because it is only called once. This is a subtle interaction. FnOnce relaxes capture constraints.
/// Returns a closure that consumes the data.
/// `FnOnce` allows moving captures without the `move` keyword,
/// but `move` is still idiomatic to signal intent.
fn make_onetime(msg: String) -> impl FnOnce() {
// `msg` is moved into the closure because `FnOnce` allows consumption.
// The closure owns `msg`.
|| println!("{}", msg)
}
fn main() {
let printer = make_onetime(String::from("once"));
printer();
// `printer` is consumed. Cannot call again.
}
Choosing the right trait bound avoids lifetime friction. If you need to move data and only call once, FnOnce works naturally. If you need to call multiple times, Fn with move is the path. If you need to borrow, Fn with lifetimes is the path.
Pick the trait bound that matches your usage. FnOnce for one-shot consumers. Fn for reusable handlers. FnMut for stateful callbacks.
Realistic scenario: event handlers
GUI frameworks and event loops store closures. A button click handler might need to access configuration data. The handler is stored in a struct. The struct outlives the function that creates the handler.
/// A simple event runner that stores a callback.
struct Runner<F: Fn()> {
callback: F,
}
impl<F: Fn()> Runner<F> {
/// Creates a new runner with the given callback.
fn new(cb: F) -> Self {
Runner { callback: cb }
}
/// Executes the stored callback.
fn run(&self) {
(self.callback)();
}
}
/// Sets up a runner with a config string.
/// Uses `move` to pack the config into the closure.
fn setup_runner() -> Runner<impl Fn()> {
let config = String::from("debug_mode=true");
// The closure captures `config` by move.
// The Runner stores the closure. The config lives inside the closure.
Runner::new(move || println!("Running with config: {}", config))
}
fn main() {
let runner = setup_runner();
runner.run();
// Config is dropped when runner is dropped.
}
This pattern is common. The Runner struct holds the closure. The closure holds the data. Everything is owned. No lifetimes needed. The move keyword is the key. Without move, the compiler tries to capture config by reference. The reference would dangle when setup_runner returns. move fixes it.
Convention aside: In callback systems, move is idiomatic. Callbacks are often stored or sent to other threads. Moving data into the callback isolates it from the creation scope. It prevents lifetime errors and makes the callback self-contained. Reach for move first when building closures for storage.
Pitfalls and compiler errors
Lifetimes in closures cause specific errors. Recognizing the pattern speeds up debugging.
E0515 (returned value contains a reference to a local variable) appears when a closure captures a local reference and is returned or stored. The fix is move or lifetime annotations.
E0597 (borrowed value does not live long enough) appears when a closure borrows data that is dropped before the closure is used. The compiler points to the borrow site and the drop site. Extend the data's scope or move it.
E0308 (mismatched types) often hides lifetime mismatches. If you have impl Fn() + 'a but pass a closure with a shorter lifetime, the compiler reports a type mismatch. The lifetimes do not unify. Check the lifetime parameters on the function signature and the closure capture.
Another pitfall is over-annotating. Adding lifetime parameters when move would work adds complexity. move is simpler. It removes lifetime dependencies. Use lifetimes only when borrowing is necessary. Borrowing is necessary when data is shared, or when the data type does not implement the required traits for ownership, or when performance demands avoiding allocation.
Counter-intuitive but true: the more you use move, the fewer lifetime annotations you need. Moving data breaks the link to the creation scope. It simplifies the lifetime graph.
Decision matrix
Choose the right approach based on your data flow and closure usage.
Use move when the closure outlives the scope where the data is created. Move the data into the closure so it travels with the backpack. This is the standard fix for stored closures and callbacks.
Use lifetime annotations when the closure borrows data that lives longer than the function creating the closure. Tie the closure's lifetime to the data's lifetime so the compiler knows the reference stays valid. This applies when data is shared or passed from an outer scope.
Use FnOnce when you need to move data but only call the closure once. The trait bound allows moving captures that Fn forbids without move. This is useful for one-shot consumers like thread spawners or resource initializers.
Use Rc or Arc when multiple closures need to share data without moving it, and you cannot use references due to ownership conflicts. Share a reference-counted pointer instead of the value itself. This avoids lifetime annotations while allowing shared access.
Trust the borrow checker. It usually has a point.