Academy
Move and Closures: When Your Lambda Kidnaps Your Variables
Learn how Rust's move closures transfer variable ownership to handle concurrency safely, prevent data races, and make threads and async tasks reliable.
6 min read
On this page
- Closures Capture by Reference (Default)
- Closures Capture Mutably (When They Modify)
- Move Closures: Taking Ownership
- Why Move Closures Exist: Threads
- Move Closures with Multiple Variables
- Returning Closures from Functions
- Broader Implications: Move Closures as Ownership Boundaries
- Practical Applications & Strategic Takeaways
- Our Commitment to Open Knowledge
- Support Our Mission & Get Your Handbook
Closures in most languages are straightforward: they capture variables from their surrounding scope and use them. In JavaScript, closures capture by reference. In Python, closures capture by reference. In Java, lambdas capture effectively-final variables by value. Simple, uniform, and—in the case of JavaScript and Python—occasionally the source of bugs when the captured variable changes unexpectedly.
Rust, being Rust, gives you precise control over how closures capture variables. A closure can borrow a variable immutably, borrow it mutably, or take ownership of it entirely. And the move keyword lets you force a closure to take ownership of everything it captures, which is essential for one of the most common patterns in concurrent Rust: sending a closure to another thread.
The move keyword on a closure is like telling the closure: "Take these variables. They're yours now. The original scope doesn't get them back." This sounds aggressive, but it's exactly what you need when a closure will outlive the scope that created it—which happens every time you spawn a thread, create an async task, or return a closure from a function.
At RantAI, move closures are how our async pipelines transfer data between tasks. Each task owns its data exclusively, preventing data races by construction. This article, drawn from Chapter 9, Section 9.3.3 of our guide "The Rust Programming Language," covers how closures interact with ownership and when to use the move keyword.
Closures Capture by Reference (Default)#
fn main() {
let name = String::from("Alice");
let greet = || println!("Hello, {}!", name); // Borrows name
greet(); // Works
greet(); // Works again
println!("{}", name); // name is still valid — it was only borrowed
}
By default, closures borrow the minimum they need. greet only reads name, so it captures an immutable reference. The original name remains valid.
Closures Capture Mutably (When They Modify)#
fn main() {
let mut count = 0;
let mut increment = || { count += 1; }; // Mutably borrows count
increment();
increment();
increment();
// Can't use count while increment exists (mutable borrow active)
// After increment goes out of scope (or is last used), count is accessible:
println!("Count: {}", count); // 3
}
When a closure modifies a captured variable, it captures a mutable reference. While the closure holds the mutable borrow, no other code can access the variable.
Move Closures: Taking Ownership#
fn main() {
let name = String::from("Alice");
let greet = move || {
println!("Hello, {}!", name); // name is OWNED by the closure
};
greet();
// println!("{}", name); // COMPILE ERROR: name was moved into the closure
}
The move keyword forces the closure to take ownership of all captured variables. After the closure is created, the original variables are invalid. The closure owns the data exclusively.
Why Move Closures Exist: Threads#
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
// Without move: COMPILE ERROR
// The closure might outlive data (thread might run after main exits)
// let handle = thread::spawn(|| println!("{:?}", data));
// With move: ownership transfers to the thread
let handle = thread::spawn(move || {
println!("Thread owns data: {:?}", data);
});
// data is invalid here — the thread owns it
handle.join().unwrap();
}
When you spawn a thread, the closure must own its data because the thread might outlive the scope that created the closure. move ensures the thread has exclusive ownership, preventing the spawning scope from accessing (or freeing) the data while the thread is using it.
Move Closures with Multiple Variables#
use std::thread;
fn main() {
let name = String::from("Worker-1");
let tasks = vec!["task-a", "task-b", "task-c"];
let config = 42u32; // Copy type
let handle = thread::spawn(move || {
// name: moved (String doesn't implement Copy)
// tasks: moved (Vec doesn't implement Copy)
// config: copied (u32 implements Copy — original still valid, but move keyword
// causes a copy into the closure)
println!("{} processing {:?} with config {}", name, tasks, config);
});
// name and tasks are invalid
// config is still valid (it was copied, not moved) but move keyword means
// the closure got its own copy
println!("Config in main: {}", config); // Still works for Copy types!
handle.join().unwrap();
}
For Copy types, move creates a copy inside the closure rather than transferring ownership. The original remains valid. For non-Copy types, move transfers ownership and invalidates the original.
Returning Closures from Functions#
fn make_greeter(name: String) -> impl Fn() {
move || println!("Hello, {}!", name) // name must be moved into the closure
// Without move, name would be dropped when make_greeter returns
// and the closure would hold a dangling reference
}
fn main() {
let greet = make_greeter(String::from("Alice"));
greet(); // "Hello, Alice!"
greet(); // "Hello, Alice!"
}
When returning a closure from a function, move is almost always required. Without it, the closure would borrow local variables that are freed when the function returns—creating dangling references.
Broader Implications: Move Closures as Ownership Boundaries#
At RantAI, move closures define ownership boundaries between concurrent tasks. Each tokio::spawn(move || { ... }) creates a self-contained unit of work that owns all its data. No shared state, no data races, no synchronization needed (unless explicitly opted into via Arc<Mutex<T>>).
Practical Applications & Strategic Takeaways#
For newcomers: If the compiler says "closure may outlive the current function" or "borrowed value does not live long enough," add move to the closure. This is almost always the correct fix.
For async developers: tokio::spawn requires move closures because async tasks may outlive the spawning scope. Get comfortable with move || async move { ... } for async task creation.
For architects: Move closures are natural boundaries for concurrent work units. Each closure owns its data, making concurrent composition safe and straightforward.
Our Commitment to Open Knowledge#
RantAI is committed to open education. Move and closures are covered in Chapter 9, Section 9.3.3 of our guide, "The Rust Programming Language," freely available online.
Explore these concepts further: https://trpl.rantai.dev
Support Our Mission & Get Your Handbook#
Get the Handbook on Amazon KDP: https://www.amazon.com/dp/B0DHCMD3F2
Get the Handbook on Google Play Books: https://play.google.com/store/books/details?id=INwfEQAAQBAJ
How do you use move closures in your code? Threads, async tasks, callbacks? Share your patterns!
#RustLang #Closures #MoveKeyword #Ownership #RantAI #LearnRust #Concurrency #AsyncRust #Threads #SystemsProgramming
Next article
Custom Types and Move Semantics: Drop, Destructors, and Deterministic Cleanup