Send and Sync in Rust Explained and Why They Are Auto Traits
Send means a value can move to another thread and Sync means a reference to it can be shared. See which types lack them and why the compiler decides for you.

Send and Sync are the two marker traits that make Rust's thread-safety claim checkable. A type is Send if it can be moved to another thread, and Sync if a shared reference to it can be used from several threads at once. Neither has a method. The compiler derives them automatically from a type's fields, which is why you meet them first as an error message about Rc and later as a source of confusion when an async block refuses to spawn. This post covers the two definitions, the standard types on each side of the line, how to read the error, how to opt out and in, and what an unsafe impl actually promises. Everything compiled on Rust 1.99, edition 2024.
The definitions, and the identity that links them
Send: ownership of a value of this type may be transferred to another thread. Sync: it is safe to share a &T between threads. The two are connected by one identity that is worth memorising: T is Sync exactly when &T is Send. Sharing a reference across threads means sending that reference to them, so the question "can many threads read this?" and "can the reference be moved?" are the same question.
They are auto traits: the compiler implements them for a struct if every field implements them, with no code from you. The marker is also unsafe to implement by hand, because a wrong impl lets safe code create a data race. Between them these traits are the entire mechanism behind Rust's "fearless concurrency". There is no runtime check, no annotation on spawn, and no special thread rules in the language; thread::spawn simply requires F: Send + 'static.
The same type, spawned two ways
use std::sync::Arc;
use std::thread;
fn main() {
let shared = Arc::new(vec![1, 2, 3]);
let handles: Vec<_> = (0..3)
.map(|i| {
let data = Arc::clone(&shared);
thread::spawn(move || data.iter().sum::<i32>() * i)
})
.collect();
for h in handles {
println!("{}", h.join().unwrap());
}
}0
6
12That runs because Arc<Vec<i32>> is Send: its reference count is atomic. Swap in Rc, which has a plain integer count, and the compiler refuses before a thread starts:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(vec![1, 2, 3]);
let data = Rc::clone(&shared);
let h = thread::spawn(move || data.len());
println!("{}", h.join().unwrap());
}error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
--> src/main.rs:7:27
|
7 | let h = thread::spawn(move || data.len());
| ------------- -------^^^^^^^^^^^
| | |
| | `Rc<Vec<i32>>` cannot be sent between threads safely
| | within this `{closure@src/main.rs:7:27: 7:34}`
| required by a bound introduced by this call
|
= help: within `{closure@src/main.rs:7:27: 7:34}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`
note: required because it's used within this closure
--> src/main.rs:7:27
|
7 | let h = thread::spawn(move || data.len());
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/std/src/thread/functions.rs:125:0Read the error from the top. The headline names the trait (Rc<Vec<i32>> cannot be sent between threads safely). The first note points to the closure as the thing being sent, and the line about Rc explains why, with a pointer to the implementation. Two threads incrementing a non-atomic count could lose an update, free the vector early, and cause a use-after-free; the type system forbids the situation by making Rc !Send. The full comparison of the pointer types is in Box, Rc and Arc.
Which standard types are on which side
| Type | Send | Sync | Why |
|---|---|---|---|
i32, String, Vec<T> (T: Send) | Yes | Yes | Plain owned data |
Arc<T> | If T: Send + Sync | If T: Send + Sync | Atomic count, shared access to T |
Rc<T> | No | No | Non-atomic count |
Cell<T>, RefCell<T> | If T: Send | No | Unsynchronised mutation through &self |
Mutex<T> | If T: Send | If T: Send | The lock makes shared access exclusive |
MutexGuard<T> | No | If T: Sync | Some platforms require the unlocking thread to be the locking one |
*const T, *mut T | No | No | The compiler assumes nothing about raw pointers |
AtomicUsize and friends | Yes | Yes | Designed for sharing |
Two rows reward a second look. Mutex<T> is Sync even when T is only Send: the lock ensures just one thread at a time touches the T, so sharing the mutex does not share the value. And RefCell is Send but not Sync: you can hand the whole thing to another thread, but two threads cannot hold &RefCell because its borrow flag is not atomic. Combining Arc with RefCell is a classic wrong guess, and the compiler's answer is Mutex or RwLock, as shown in the Arc and Mutex pattern.
Asserting a type's status at compile time
To check what your own type is, rather than learn it from a spawn error somewhere else, add a tiny function that only compiles if the bound holds. It costs nothing at runtime:
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
struct Job {
id: u64,
log: Arc<Mutex<Vec<String>>>,
}
struct LocalCache {
hot: RefCell<Vec<u8>>,
}
struct Handle {
name: Rc<String>,
}
fn main() {
assert_send::<Job>();
assert_sync::<Job>();
assert_send::<LocalCache>();
println!("Job is Send + Sync, LocalCache is Send");
let _ = (Job { id: 1, log: Arc::default() }.id, LocalCache { hot: RefCell::default() }.hot, Handle { name: Rc::new(String::new()) }.name);
assert_sync::<LocalCache>();
}error[E0277]: `RefCell<Vec<u8>>` cannot be shared between threads safely
--> src/main.rs:27:19
|
27 | assert_sync::<LocalCache>();
| ^^^^^^^^^^ `RefCell<Vec<u8>>` cannot be shared between threads safely
|
= help: within `LocalCache`, the trait `Sync` is not implemented for `RefCell<Vec<u8>>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
note: required because it appears within the type `LocalCache`
--> src/main.rs:13:8
|
13 | struct LocalCache {
| ^^^^^^^^^^
note: required by a bound in `assert_sync`
--> src/main.rs:6:19
|
6 | fn assert_sync<T: Sync>() {}
| ^^^^ required by this bound in `assert_sync`Libraries put these assertions in their test suites so that a refactor that adds an Rc field to a public type fails a test instead of breaking downstream users. The pattern is cheap insurance for any type you publish with the promise "you can share this".
The async variant: Send futures
The same rule reaches async fn. A future is a struct holding every variable that lives across an .await, so if one of them is not Send, the whole future is not, and tokio::spawn (which may run it on any worker thread) rejects it. The usual culprit is a MutexGuard or an Rc held across an await. The error is long and sometimes cryptic, and its full treatment is in future cannot be sent between threads safely. For the principle, remember: the thread-safety of a future is the thread-safety of its captured state.
Edition 2021 closures capture fields, not the whole value
One subtlety in the examples that follow will cost you an afternoon if you meet it unprepared. Since edition 2021 a closure captures only the fields it uses. A move closure that reads t.id moves just the u32 into the thread, and t itself never crosses, so the check on ThreadBound never fires. The same closure that reads a *mut u8 field captures the raw pointer on its own, which is !Send even though the struct around it carries an unsafe impl Send. To make the closure take the whole value, bind it inside the closure with let t = t;, which is what the two listings below do.
Opting out and opting in
An auto trait can be removed from a type by embedding a field that lacks it. The stable way is PhantomData<*const ()> (raw pointers are neither Send nor Sync), which costs no space and makes a handle that must stay on its creating thread. That is useful for wrapping thread-affine resources: a GUI toolkit handle, a thread-local arena, a C library object that is documented as not thread-safe.
use std::marker::PhantomData;
use std::thread;
struct ThreadBound {
id: u32,
_not_send: PhantomData<*const ()>,
}
fn main() {
let t = ThreadBound { id: 7, _not_send: PhantomData };
let h = thread::spawn(move || {
let t = t;
t.id
});
println!("{}", h.join().unwrap());
}error[E0277]: `*const ()` cannot be sent between threads safely
--> src/main.rs:11:27
|
11 | let h = thread::spawn(move || {
| ------------- ^------
| | |
| _____________|_____________within this `{closure@src/main.rs:11:27: 11:34}`
| | |
| | required by a bound introduced by this call
12 | | let t = t;
13 | | t.id
14 | | });
| |_____^ `*const ()` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:27: 11:34}`, the trait `Send` is not implemented for `*const ()`
note: required because it appears within the type `PhantomData<*const ()>`
--> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/core/src/marker.rs:811:11
note: required because it appears within the type `ThreadBound`
--> src/main.rs:4:8
|
4 | struct ThreadBound {
| ^^^^^^^^^^^
note: required because it's used within this closure
--> src/main.rs:11:27
|
11 | let h = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/std/src/thread/functions.rs:125:0Opting in is the dangerous direction. A type containing a raw pointer is neither trait, even when you know the pointer is used safely, so you promise it with unsafe impl Send for T {}. The word unsafe is the point: you are telling the compiler "I have checked the data race argument that you cannot".
use std::thread;
struct Buffer {
ptr: *mut u8,
len: usize,
}
// SAFETY: Buffer owns the allocation exclusively. No other alias to ptr exists,
// so moving the owner to another thread cannot create a data race.
unsafe impl Send for Buffer {}
impl Buffer {
fn new(len: usize) -> Self {
let mut v = vec![0u8; len].into_boxed_slice();
let ptr = v.as_mut_ptr();
std::mem::forget(v);
Buffer { ptr, len }
}
}
impl Drop for Buffer {
fn drop(&mut self) {
unsafe { drop(Box::from_raw(std::slice::from_raw_parts_mut(self.ptr, self.len))) }
}
}
fn main() {
let b = Buffer::new(16);
let h = thread::spawn(move || {
let b = b;
unsafe { *b.ptr = 42 };
unsafe { *b.ptr }
});
println!("{}", h.join().unwrap());
}42Write the justification as a comment above every such impl, naming the invariant. Do not add unsafe impl Sync unless you have also made every method that takes &self safe to call concurrently, which usually means an internal lock or atomics. The test that tells you that you got it wrong is cargo miri test with the data-race detector, covered in Miri and undefined behavior.
What these traits do not guarantee
They prevent data races, which is a specific kind of bug: two unsynchronised accesses to the same memory with at least one write. They do not prevent deadlocks (two threads each holding a lock the other wants), logic races (checking a condition and acting on it as two separate locked steps), or starvation. A program made entirely of safe, Send and Sync code can still hang. For the cases where you want a runtime check of thread-confined data, thread_local! and single-threaded runtimes such as LocalSet in Tokio's LocalSet are the intended tools.


