From Scope to Thread: Mastering Closure Variable Captures in Rust
Exploring the why and how of capturing variables inside a closure running in a new thread in Rust

In Rust, one of the most fascinating (and sometimes head-scratching) features is how closures capture variables from their environment—especially when you involve threads. If you’ve ever tried to spawn a new thread and pass data into the closure without using move
, you’ve likely been greeted by the compiler with a wall of text about lifetimes, borrowing, and ownership.
In this blog, we will explore the why and how of capturing variables inside a closure running in a new thread. We’ll start with a simple example that fails to compile, examine why that is, and then fix it step by step. Along the way, we’ll discuss key Rust concepts like ownership, borrowing, Send
, Sync
, and why they matter so much for concurrency.
Let’s jump in.
The First Failure: Capturing Without move
A classic first attempt at threading in Rust looks something like this:
use std::thread;
fn main() {
let message = String::from("Hello, Rust!");
let handle = thread::spawn(|| {
// Trying to capture 'message' by reference implicitly
println!("{}", message);
});
handle.join().unwrap();
}
without move
You might think this is perfectly reasonable: we have a message
string, we spawn a new thread and print the message
inside our closure. But Rust’s compiler has a different opinion:
error[E0373]: closure may outlive the current function, but it borrows `message`, which is owned by the current function
--> src/main.rs:6:23
|
6 | let handle = thread::spawn(|| {
| ^^^^^^ may outlive borrowed value `message`
7 | println!("{}", message);
| ------- `message` is borrowed here
Why the Error?
- Thread Lifetime: The new thread could, in theory, outlive the function
main
. Oncemain
ends,message
goes out of scope. - Borrow Checker: Rust won’t let a reference outlive the data it points to—this would be a dangling reference.
In other words, if the main thread exits before the spawned thread prints message
, we end up with a reference to memory that no longer exists.
The Fix: Capturing by Value with move
The compiler error disappears if we add the move
keyword:
use std::thread;
fn main() {
let message = String::from("Hello, Rust!");
let handle = thread::spawn(move || {
// Now we're taking ownership of 'message'.
println!("{}", message);
});
// you cannot use message here,
// since the ownership of the variable is moved to the new thread
handle.join().unwrap();
}
with move
Why move
Solves It
- Ownership Transfer: With
move
, the closure owns themessage
. It’s no longer borrowing from the outer scope. - No Dangling: Because the closure owns
message
, the data remains valid until the closure (and thus the thread) is done. Even ifmain
finishes, themessage
for that thread is safely stored elsewhere.
Rust’s ownership rules ensure that once move
is used, you can’t accidentally use message
in the outer scope, preventing invalid access.
The Next Hurdle: Using References Inside the Closure
Now, let’s say we try to do something more “clever.” Instead of moving the entire data, we only move a reference to the data:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let reference_to_data = &data; // Immutable reference
let handle = thread::spawn(move || {
// This 'move' only captures the reference, not the actual vector.
println!("Reference to vector: {:?}", reference_to_data);
});
// If 'data' goes out of scope here, 'reference_to_data' is invalid.
handle.join().unwrap();
}
Spoiler: This still causes a compile error (E0597), complaining that data
does not live long enough:
error[E0597]: `data` does not live long enough
--> src/main.rs:3:29
|
2 | let data = vec![1, 2, 3];
| ---- binding `data` declared here
3 | let reference_to_data = &data; // This is an immutable reference
| ^^^^^ borrowed value does not live long enough
...
6 | let handle = std::thread::spawn(move || {
| _________________________________-
7 | | println!("Reference to vector: {:?}", reference_to_data);
8 | | });
| |______- argument requires that `data` is borrowed for `'static`
...
13 | }
| - `data` dropped here while still borrowed
Why Isn’t move
Enough with References?
- When we do
move || { ... }
, only the reference (&data
) is moved. The main function still owns the underlying vector. - As soon as
main
function ends (or even ifdata
goes out of scope before the thread finishes), that reference points to memory that’s no longer valid.
This leads to a potential dangling pointer in the thread if it outlives the scope in which data
was allocated.
Ownership vs. Borrowing for References
- Moving a reference does not move the original data—only ownership of the reference itself is transferred.
- The actual data (
data
in this case) is still owned by the original scope, with its own lifetime constraints.
Safely Accessing Data Across Threads
To safely share data across threads without transferring full ownership, Rust provides concurrency primitives such as Arc
(an Atomic Reference Counting smart pointer) and Mutex
(for mutual exclusion). One common pattern:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// Wrap 'data' inside an Arc so multiple threads can own it safely.
let shared_data = Arc::new(data);
let handle = {
// Clone the Arc to get another reference-counted pointer.
let data_for_thread = Arc::clone(&shared_data);
thread::spawn(move || {
// The closure now owns an Arc, guaranteeing the data won't be dropped
// until all Arcs are out of scope.
println!("Cloned data in thread: {:?}", data_for_thread);
})
};
handle.join().unwrap();
// 'shared_data' is still accessible here, because Arc ensures
// the data wasn't dropped prematurely.
println!("Original data in main: {:?}", shared_data);
}
Why This Works
- Arc: Ensures multiple owners can hold the data concurrently, and it won’t be dropped until the last owner (
Arc
) goes out of scope. - No Dangling References: Each thread has a valid handle to the data.
- Thread-Safety: If you need to mutate the data, you’d pair
Arc
withMutex
(or another synchronization primitive).
5. The Role of Send
(and Sync
)
When you do thread::spawn
, the closure and all its captures must be Send
. This trait means:
“It is safe to move the type to another thread.”
Send
Trait
- Most Primitive Types Are
Send
:i32
,bool
,char
, etc., are allSend
. - Complex Types: A struct is
Send
if all of its fields areSend
. If you have a type that internally holds something that isn’tSend
, then the entire struct fails to beSend
. - Transfer of Ownership: Even if you transfer ownership, a type must still implement
Send
to safely move across threads. Some types can’t be moved across threads (for example, raw pointers or certain FFI types) because the compiler can’t guarantee thread safety.
Sync
Trait
Closely related is the Sync
trait:
“It is safe for the type to be referenced from multiple threads.”
- If
&T
isSend
, thenT
isSync
. - Typically, types that can be shared among threads immutably implement
Sync
. Arc<T>
is bothSend
andSync
ifT
isSend + Sync
.Mutex<T>
is alsoSend + Sync
ifT
isSend
, etc.
Send
+ Sync
Together
- A type that is both
Send
andSync
can be transferred across threads and shared across threads safely (with the right concurrency primitives if mutation is involved). - In multi-threaded Rust code, you’ll often see these two traits appear together—especially in libraries or data structures that need to support concurrent operations.
Putting It All Together
- Capturing Data Without
move
: Leads to errors if the thread can outlive the scope, because references become invalid. - Using
move
: Transfers ownership into the closure, ensuring the data lives as long as the thread needs it. This is often the simplest fix. - References +
move
: Merely moving a reference doesn’t move the underlying data, so you can still end up with lifetime issues unless the data is'static
or managed by concurrency primitives likeArc
. Send
andSync
: Even if you properly move data, it must implementSend
to be transferred to another thread. If multiple threads need to share data safely, the data (or references to it) must also implementSync
or use synchronization wrappers.
Conclusion
Rust’s closure capture rules might initially feel strict, but they are pivotal in ensuring memory safety and data race freedom. When you combine these rules with the Send
and Sync
traits, you get a concurrency model that might be more verbose than in other languages but yields a high guarantee of correctness.
- Need to own data in another thread? Use
move
. - Need to share data across threads? Reach for
Arc
(andMutex
/RwLock
if you need mutable access). - Confused about references? Remember that references don’t extend the data’s lifetime—they just borrow. For cross-thread usage, the data must remain valid (owned or
'static
).
Happy coding, and may your threads never dangle a reference again!