Understanding Rust closures in depth: a practical guide for developers
Closures are one of Rust's most powerful features, yet they're often misunderstood because they interact deeply with the borrow checker. Unlike functions, closures can capture variables from their environment—but how they do so has profound implications for your code's correctness and performance. Understanding the three closure traits—Fn, FnMut, and FnOnce—will unlock cleaner APIs, safer concurrent code, and fewer compilation surprises.
What are closures and why the traits matter
A closure is an anonymous function that can capture variables from its enclosing scope. Rust represents every closure as a unique, unnamed type that implements one or more of the traits Fn, FnMut, or FnOnce. These traits exist on a hierarchy: Fn is the most restrictive, FnMut is in the middle, and FnOnce is the most permissive. When we pass a closure to a function or store it, we're really specifying which trait the closure must satisfy.
The trait determines not just what the closure can do internally, but how the borrow checker reasons about the captured variables. This is critical when building higher-order functions, working with iterators, spawning threads, or handling async tasks.
Fn: borrowing immutably
A closure implements Fn when it only reads captured variables without modifying them. With Fn, the closure borrows its captured environment immutably. This is the most restrictive trait, which means it's also the most flexible for the caller—you can call a Fn closure multiple times, and you can call it concurrently without synchronization.
Real-world scenario: Suppose we're building a filtering system in an e-commerce platform where we need to filter products based on a user-defined predicate.
fn filter_products<F>(products: Vec<Product>, filter: F) -> Vec<Product>
where
F: Fn(&Product) -> bool,
{
products.into_iter().filter(filter).collect()
}
struct Product {
name: String,
price: u32,
}
let products = vec![
Product { name: "Laptop".to_string(), price: 1200 },
Product { name: "Mouse".to_string(), price: 25 },
];
let min_price = 100;
let expensive = filter_products(products, |p| p.price >= min_price);
The closure |p| p.price >= min_price captures min_price immutably. It reads it but never modifies it. The Fn bound means the caller can invoke this closure as many times as needed without worrying about side effects or mutable borrowing conflicts.
In production systems handling requests in parallel, immutable captures are invaluable. Multiple worker threads can safely call the same Fn closure concurrently because no thread is modifying shared state.
FnMut: borrowing mutably
A closure implements FnMut when it mutates captured variables. The closure borrows its environment mutably, which means it can modify captured state between calls. You can still call a FnMut multiple times, but sequential calls may have different behavior because the captured state changes.
Real-world scenario: Imagine building a request aggregator for a ride-hailing service where we accumulate statistics across multiple API responses.
fn process_responses<F>(responses: Vec<Response>, mut processor: F)
where
F: FnMut(&Response),
{
for response in responses {
processor(&response);
}
}
struct Response {
distance: f32,
duration: u32,
}
let mut total_distance = 0.0;
let mut ride_count = 0;
process_responses(vec![
Response { distance: 5.2, duration: 600 },
Response { distance: 3.1, duration: 420 },
], |resp| {
total_distance += resp.distance;
ride_count += 1;
});
println!("Completed {} rides, {} km total", ride_count, total_distance);
The closure |resp| { total_distance += resp.distance; ride_count += 1; } mutates both total_distance and ride_count. This is a FnMut because we modify captured variables. Note the mut keyword on the processor parameter—the function itself takes a mutable reference to the closure.
FnMut is perfect for stateful operations: building up a result, maintaining a counter, or accumulating error logs. However, you cannot call a FnMut closure concurrently from multiple threads without explicit synchronization (like Mutex), because concurrent mutable access would violate Rust's safety guarantees.
FnOnce: consuming captured values
A closure implements FnOnce when it takes ownership of captured variables, moving them into the closure. Once called, the closure has consumed those values and cannot be called again. FnOnce is implemented by every closure, but if a closure also implements FnMut or Fn, we prefer those more specific traits.
Real-world scenario: Consider a task queue in a trading system where each task owns its data and cannot be reused.
fn execute_task<F>(task: F)
where
F: FnOnce(),
{
task();
}
let trade_order = vec![100, 200, 300];
let trade_id = "TRD-001".to_string();
execute_task(|| {
println!("Executing trade {} with sizes: {:?}", trade_id, trade_order);
// trade_id and trade_order are moved into the closure.
// After this closure runs, they're gone.
});
// This would fail to compile:
// println!("{}", trade_id); // error: trade_id has been moved
The closure moves both trade_order and trade_id into itself. After the closure runs, those values are consumed. Attempting to use them again causes a compilation error. FnOnce is the right choice when you want the closure to take full ownership of values and you have no intention of calling it multiple times.
Another compelling use case is with async spawning: when you spawn an async task, the closure passed to it must be FnOnce because the runtime owns the closure and executes it exactly once.
move: forcing value capture by the closure
By default, closures capture variables by reference (either immutably or mutably depending on usage). The move keyword forces the closure to take ownership of all captured variables. This is essential in two scenarios: spawning threads and writing async code.
Threads: When spawning a thread, the closure must own all its captured data because the thread's lifetime is independent of the scope where the closure was defined.
use std::thread;
let user_id = 42;
let request_data = vec!["query1", "query2"];
let handle = thread::spawn(move || {
println!("Processing user {} with data: {:?}", user_id, request_data);
// user_id and request_data are owned by this closure.
// The thread can run even after the outer scope ends.
});
handle.join().unwrap();
Without move, the compiler would reject this because user_id and request_data live in the main scope, and the thread might outlive them. The move keyword transfers ownership into the closure, which the thread then owns exclusively.
Async tasks: Similarly, when spawning an async task—whether with Tokio or another runtime—the closure often needs to own captured values.
async fn fetch_user_data(user_id: u32) {
let cache_key = format!("user_{}", user_id);
tokio::spawn(async move {
// cache_key is now owned by this async task.
println!("Cache key: {}", cache_key);
});
}
The move keyword (applied to the async block, which is itself a closure) ensures that cache_key is owned by the task and won't be dropped prematurely.
Closure trait bounds in practice
When designing APIs, we specify closure trait bounds to express exactly what behavior we need. More restrictive bounds give callers more flexibility; less restrictive bounds constrain the caller but offer you more power.
// Highly constrained: requires Fn.
// Callers can pass any closure, even if it only reads.
// We can call this closure multiple times, in any order.
fn map_with_fn<F, T, U>(values: Vec<T>, f: F) -> Vec<U>
where
F: Fn(T) -> U,
{
values.into_iter().map(f).collect()
}
// Medium constraint: requires FnMut.
// Closures can mutate captured state.
// We can call it multiple times, but state may change between calls.
fn fold_with_fnmut<F, T, Acc>(values: Vec<T>, init: Acc, mut f: F) -> Acc
where
F: FnMut(Acc, T) -> Acc,
{
values.into_iter().fold(init, f)
}
// Least constrained: requires FnOnce.
// The closure takes full ownership and is called exactly once.
fn run_once<F>(f: F)
where
F: FnOnce(),
{
f();
}
Common mistakes
Mistake 1: Forgetting move in threads. A common error is writing a closure that captures variables without move, then passing it to thread::spawn. The compiler rejects this because the thread might outlive the scope.
// This won't compile:
let count = 5;
thread::spawn(|| println!("Count: {}", count));
// error: closure may outlive the current function
// Fix: add move
thread::spawn(move || println!("Count: {}", count));
Mistake 2: Over-constraining with Fn. Beginners sometimes use Fn trait bounds when FnMut would suffice. This unnecessarily limits the closures your API can accept.
Mistake 3: Misunderstanding capture by reference. Closures capture by reference by default. If you need to move values, you must use move explicitly. Forgetting this can lead to lifetime issues.
Key takeaways
- Fn: Borrows captured variables immutably. Most flexible for the caller; can be called multiple times, often concurrently, without side effects.
- FnMut: Borrows captured variables mutably. Allows the closure to modify captured state; still callable multiple times but in a sequential context.
- FnOnce: Takes ownership of captured variables. Can be called only once; useful for one-time operations and task ownership semantics.
- move: Forces ownership transfer. Essential for threads and async tasks where the closure must own its captured data independently of its definition scope.
- API design: Choose the least restrictive trait bound that meets your needs. Start with
Fn, useFnMutfor stateful operations, and reach forFnOnceonly when you need ownership semantics. - Concurrency: Only
Fnclosures are safely callable from multiple threads without synchronization.FnMutandFnOnceclosures require careful lifetime and ownership management in concurrent contexts.






Comments
Loading comments…