Closures and Safety
How Rusts ownership and borrowing system makes closures memory-safe, prevents dangling references and data races, and what guarantees the Fn, FnMut, and FnOnce traits provide.
Rust’s entire safety model—the borrow checker, ownership, and lifetimes—applies to closures with no special exemptions. A closure that captures a reference cannot outlive the thing it points to. A closure moved into another thread must be Send. These constraints are not extra rules for closures; they are the same rules that govern all Rust code, enforced automatically when you write || { ... }.
The result is that when a closure compiles, you know it will not cause use‑after‑free, dangling pointers, or data races. This page explains exactly how the compiler keeps that promise, what can go wrong when you write a closure, and what the Fn, FnMut, and FnOnce traits tell you about the safety of calling a closure.
How the Borrow Checker Applies to Closures
A closure that uses a variable from its outer scope is capturing that variable. The capture can be an immutable borrow (&T), a mutable borrow (&mut T), or an ownership transfer (T). The closure’s body determines which is required, and the borrow checker enforces all the usual rules around the captured variables for as long as the closure exists.
The simplest case is an immutable borrow. The closure can read the variable, but no one else may mutate it while the borrow is active. Consider this:
fn main() {
let name = String::from("Ferris");
let greet = || println!("Hello, {}!", name);
// name.push_str(" 🦀"); // ❌ Would not compile – name is immutably borrowed
greet();
}
The closure greet only reads name, so it captures &name. Rust treats that borrow as live from the point the closure is defined until the closure is last used (or goes out of scope). During that window, name cannot be mutated. If you uncomment the push_str line, the compiler will refuse the mutable borrow because the immutable borrow already exists. This is the borrow checker doing exactly what it always does—the fact that the borrower is a closure changes nothing.
Borrow Lifetimes Are Tied to the Closure:
The borrow lasts as long as the closure is alive, not just until the closure is called. If you store the closure in a variable and pass it around, the borrow lives with it. This can keep a lock on data longer than you might expect.
Once the closure is dropped or its last use passes, the borrow ends. The variable becomes available again for mutation or moving, exactly as if a regular reference went out of scope.
Move Closures and Ownership Transfer
When a closure takes ownership of a captured value, it uses the move keyword. This forces the closure to move every captured variable into itself. After that point, the original variable is no longer usable in the outer scope.
fn main() {
let secret = String::from("hidden");
let consume = move || {
println!("The secret is: {}", secret);
// secret is dropped when consume goes out of scope
};
// println!("{}", secret); // ❌ error: borrow of moved value
consume();
}
The compiler ensures that secret is moved into the closure. After that, the outer code cannot read or write secret. This prevents both a use‑after‑move and the situation where you might accidentally think you still have access to data that now lives exclusively inside the closure.
Calling a move closure more than once can fail:
A move closure that consumes an owned value internally (for example, by dropping it inside the body) will implement FnOnce, not Fn. That means it can be called only once. Forgetting that and trying to call it twice produces a compile error, which protects you from a double‑free or use‑after‑move inside the closure.
What the Closure Traits Reveal About Safety
Every closure automatically implements one or more of the traits Fn, FnMut, and FnOnce, depending on what the closure does with captured values. The trait hierarchy itself encodes a safety progression:
FnOnce– can be called at least once, possibly consuming captured values.FnMut– can be called multiple times and may mutate captured values; it also implementsFnOnce.Fn– can be called multiple times, only reads captured values; implements bothFnMutandFnOnce.
When you receive a closure as a parameter with a bound like F: FnMut(), you are telling the caller: “I promise to call this closure one or more times, and I may mutate its environment.” The type system ensures the closure you get actually supports that. If you try to pass a closure that requires ownership of a captured String to a function expecting FnMut, the compiler will stop you—because that closure can only be called once.
fn call_twice<F: FnMut()>(mut f: F) {
f();
f();
}
fn main() {
let message = String::from("done");
// A closure that moves message: it implements FnOnce, not FnMut.
let once = move || println!("{}", message);
// call_twice(once); // ❌ compile error: FnOnce not accepted
}
This is a safety guarantee. The compiler prevents you from accidentally calling a closure that would consume its captured data twice, which would be a use‑after‑move bug. The traits are not just for convenience; they are the interface that lets you reason about what a closure is allowed to do.
Captures can be more subtle than they look:
A closure that only reads a &mut reference captured from the outside might look like it should be Fn, but mutating through that reference requires &mut self on the closure itself, so the closure implements FnMut, not Fn. Rust infers the minimal required capability.
Closures and Thread Safety
Sending a closure to another thread adds the Send and Sync requirements to the captured data. Rust’s trait system automatically checks whether the closure’s captured environment is Send (for moving the closure) or Sync (for sharing it). This is what makes closures safe to use with std::thread::spawn or async runtimes.
If you spawn a thread with a closure that captures a non‑Send type such as Rc<i32>, the compiler will refuse:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(42);
// ❌ error: `Rc<i32>` cannot be sent between threads safely
// thread::spawn(move || {
// println!("{}", shared);
// });
}
The error message will tell you that Rc<i32> does not implement Send. This is the compiler preventing a data race before it can happen. Once you switch to an Arc, the closure becomes Send and the spawn succeeds.
If it compiles, it is data-race free:
When a closure compiles in a concurrent context—passed to spawn, sent across channels, or used with an async runtime—the Rust type system has already verified that the captures satisfy Send and Sync. The closure is safe to send and share across threads, with no risk of a data race from the closure itself.
Common Safety Pitfalls
Despite the compiler’s guarantees, a few patterns can lead to confusing error messages or unintended lifetime constraints. Recognizing these helps you understand what the safety checks are preventing.
Storing Closures That Borrow Local Data
A closure that borrows a local variable cannot be returned from the function or stored in a long‑lived structure, because the borrowed data would not live long enough.
fn make_printer() -> impl Fn() {
let msg = String::from("hello");
// ❌ closure borrows `msg`, which is dropped at the end of this function
// || println!("{}", msg)
}
The borrow checker sees that msg goes out of scope while the closure could still be used. It refuses to compile. The fix is either to move msg into the closure (using move) so the closure owns the data, or to restructure the code so the data lives long enough.
Accidentally Moving a Value Out of a Loop
A closure inside a loop that uses move to capture a value will take ownership of it each iteration, leaving the outer variable unavailable for the next iteration.
fn main() {
let names = vec!["Alice", "Bob"];
for name in names {
let closure = move || println!("{}", name);
closure();
// name is moved, so the next iteration would conflict if we tried to use `name` again.
}
}
In this example, the loop variable name is moved into the closure each iteration, so it works fine because we don’t reuse name. If you tried to access name after the move, the compiler would stop you. More subtle is when you want to collect closures into a Vec—each closure owns its own copy of the loop variable because of move, which is often intentional, but if you forget move, the closure borrows the loop variable and can cause lifetime issues.
move in a loop creates independent copies:
Using move inside a loop gives each closure ownership of its captured values. That can be exactly what you want when dispatching work to threads, but if you intended to share a single value, you need a different structure—for example, wrapping the value in Arc and cloning the Arc for each closure.
How the Compiler Enforces Safety Behind the Scenes
When you write a closure, the compiler generates a unique anonymous struct that holds the captured variables. The fields of that struct reflect the capture mode: a &T becomes a reference field, a &mut T becomes a mutable reference field, and a moved value becomes an owned field. The closure’s call implementation then uses these fields.
The borrow checker treats these synthetic structs exactly as it treats any other struct. If the struct contains a reference, the struct’s lifetime is tied to that reference’s lifetime. If the struct holds an owned value, it can be moved freely (subject to the usual move semantics). This is why no special “closure safety” chapter exists in the compiler—closures are just syntax sugar for a struct and trait implementation that the borrow checker already knows how to verify.
This design yields a powerful property: closures can be stored on the stack, inlined, and optimized away without any runtime cost, while still providing the same memory safety guarantees as handwritten code.
Summary
Rust closures are safe because they inherit all of Rust’s ownership and borrowing discipline. The compiler tracks captured references as if they were fields in a struct, preventing dangling borrows and use‑after‑move. The Fn/FnMut/FnOnce trait hierarchy encodes whether a closure can be called multiple times or consumes captured values—catching the kind of mistakes that would lead to double frees or use‑after‑move in other languages. When you send a closure to another thread, the Send and Sync rules ensure the captured data is thread‑safe.
The most important lesson to take away is that you do not need to think about a separate set of safety rules for closures. If you understand Rust’s borrowing and ownership for ordinary code, you understand it for closures. The compiler treats them uniformly, which means that when a closure compiles, you have the same strong safety guarantees you rely on everywhere else in Rust.