← index

Auto Traits in Rust

Send, Sync and Unpin are unlike every other trait in Rust. You almost never write impls for them. A type has them when all of its fields have them, and the compiler works that out on its own — for every type in the program, including the ones it generates itself for closures and async functions.

Rust calls these auto traits. The rule is one sentence long, and most of what is surprising about thread safety in Rust follows from it: why an Rc cannot cross a thread boundary, why adding a private field can break a crate that depends on yours, and why an async fn that compiled yesterday cannot be spawned today. This article covers how the derivation works, where it stops, how to override it in both directions, how it leaks out of types nobody wrote down — closures and futures — why a trait object is where that leak ends, and what it costs you at an API boundary.

All examples were compiled with Rust 1.96.1 (edition 2021). The two that require unstable features were compiled with nightly 1.96.0 (2026-04-06). Error messages vary slightly across compiler versions; the derivation rules themselves are stable.

What Auto Traits Are

Stable Rust has five auto traits: Send, Sync, Unpin, UnwindSafe and RefUnwindSafe. Two more, Freeze and UnsafeUnpin, sit behind feature gates for the compiler's own use: the first decides whether a static may be placed in read-only memory, the second whether a &mut T may be marked noalias. Both follow the same rules, and neither can be named on stable. The rule is easiest to see on a struct containing nothing but ordinary fields:

struct Plain {
    a: i32,
    b: String,
    c: Vec<u8>,
}

fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}

fn main() {
    assert_send::<Plain>();
    assert_sync::<Plain>();
}

It compiles. The two helper functions are the instrument used throughout this article: a generic function whose only job is to carry a bound. If assert_send::<T>() compiles then T: Send holds, and if it does not, the compiler explains precisely why. This turns an invisible property of a type into a yes-or-no question with a diagnostic attached.

Plain is Send and Sync because i32, String and Vec<u8> all are. That is the entire rule. A type is Send if every one of its fields is Send, applied recursively until it bottoms out at primitives. Nothing was declared for Plain, and nothing is declared for the overwhelming majority of types you will ever write.

One Field Decides the Whole Struct

The rule gets interesting when the compiler reaches a field that does not qualify. Swap one:

use std::rc::Rc;

struct Holder {
    id: u32,
    shared: Rc<String>,
}

fn main() {
    assert_send::<Holder>();
}

The compiler rejects it, and the wording repays a close look:

error[E0277]: `Rc<String>` cannot be sent between threads safely --> src/main.rs:14:19 | 14 | assert_send::<Holder>(); | ^^^^^^ `Rc<String>` cannot be sent between threads safely | = help: within `Holder`, the trait `Send` is not implemented for `Rc<String>` note: required because it appears within the type `Holder` --> src/main.rs:6:8 | 6 | struct Holder { | ^^^^^^

Two phrases carry the mechanism. "within Holder" and "required because it appears within the type" are the compiler narrating a field walk. It did not consult a table of !Send types and find Holder on it. It descended into the struct, found an Rc, and propagated the failure back up. The error is reported against Holder but blames a type that Holder merely contains.

Which raises the question of why Rc itself fails, and the answer is not derivation. The standard library declares it:

impl<T: ?Sized, A: Allocator> !Send for Rc<T, A> {}
impl<T: ?Sized, A: Allocator> !Sync for Rc<T, A> {}

An Rc keeps its reference count in a plain non-atomic integer. Two threads each holding a clone of the same Rc would increment that count concurrently, an update would be lost, and the allocation would eventually be freed while a live handle still pointed at it. No field walk could work that out, so the type opts out by hand. Note the syntax, because it is doing something this article will later say you cannot do.

Back to the walk itself. This is its everyday face:

let counter = Rc::new(5);
thread::spawn(move || {
    println!("{counter}");
});

thread::spawn requires its closure to be Send, the closure captures the Rc, and a closure is a struct whose fields are its captures. So this is exactly the Holder case with a type the compiler generated instead of one you declared. The rejection is the same walk over the same kind of structure.

The practical consequence is that you cannot determine whether your own type is Send by reading its definition. You have to know what its fields are made of, all the way down. A type three crates away can decide it for you.

Send and Sync: Two Different Questions

Send and Sync are routinely spoken about as a pair, which obscures that they answer different questions. Send asks whether ownership of a value may move to another thread. Sync asks whether a reference may be shared with another thread, and it is defined in terms of the first: T is Sync exactly when &T is Send.

That definition has a consequence worth seeing directly. A shared reference is Send when its target is Sync, not when its target is Send, and those are different sets of types:

assert_send::<Cell<i32>>(); // Cell itself is Send
assert_send::<&i32>(); // i32 is Sync, so &i32 is Send
assert_send::<&Cell<i32>>(); // Cell is not Sync, so this is rejected
error[E0277]: `&Cell<i32>` cannot be sent between threads safely --> src/main.rs:11:19 | 11 | assert_send::<&Cell<i32>>(); // Cell is not Sync, so this is rejected | ^^^^^^^^^^ `&Cell<i32>` cannot be sent between threads safely | = help: the trait `Sync` is not implemented for `Cell<i32>` = note: required for `&Cell<i32>` to implement `Send`

A Cell is perfectly Send. You may move one to another thread and use it there all you like. What you may not do is let two threads hold references to the same one, and the final note is that rule being applied literally.

The mirror case is stranger. &mut T is Send whenever T is Send, with no Sync requirement at all:

assert_send::<&mut Cell<i32>>(); // compiles

An exclusive reference to a Cell may cross a thread boundary while a shared one may not. That inverts the usual instinct that &mut is the more dangerous of the two, and the reason is that the hazard being guarded against is simultaneous access. An exclusive reference is itself proof that no second observer exists, so there is nothing left to synchronize. A shared reference is proof of exactly the opposite.

References pull the two traits apart in one direction: Cell is Send and not Sync. The standard library also contains a type that goes the other way, Sync and not Send. This compiles:

fn main() {
    assert_sync::<MutexGuard<'static, i32>>();
}

and this does not:

fn main() {
    assert_send::<MutexGuard<'static, i32>>();
}
error[E0277]: `std::sync::MutexGuard<'static, i32>` cannot be sent between threads safely --> src/main.rs:8:19 | 8 | assert_send::<MutexGuard<'static, i32>>(); | ^^^^^^^^^^^^^^^^^^^^^^^^ `std::sync::MutexGuard<'static, i32>` cannot be sent between threads safely | = help: the trait `Send` is not implemented for `std::sync::MutexGuard<'static, i32>`

A lock guard is Sync but not Send, and the standard library states both directly:

impl<T: ?Sized> !Send for MutexGuard<'_, T> {}
unsafe impl<T: ?Sized + Sync> Sync for MutexGuard<'_, T> {}

The Sync half is conditional. Sharing a reference to a guard is safe only when the guarded data is itself Sync, because that data is all the other thread can reach through it. The !Send half is unconditional, and the reason is an operating system constraint rather than anything about Rust: several platforms require that a mutex be released by the thread that acquired it, and moving the guard would move the destructor that performs the unlock.

Notice also that the diagnostic here says only "the trait Send is not implemented for MutexGuard" — there is no "within" clause. The failure originates at this type rather than being inherited from a field. That distinction is the difference between a root and a propagation, and the roots are a short enough list to learn.

Every Concrete Root Is a Declared Negative

If almost every type is Send and Sync by structural derivation, the question is what stops the walk. The natural assumption is that the compiler refuses when it cannot work something out for itself. It does not. The walk only ever propagates a failure it was handed, and every concrete type it stops at is one the standard library declared out by hand. The one other thing that stops it is having no fields to look at, which is what a trait object or an unbounded type parameter presents, and that case gets its own section later.

For Sync, the deepest origin is UnsafeCell:

error[E0277]: `UnsafeCell<i32>` cannot be shared between threads safely --> src/main.rs:8:19 | 8 | assert_sync::<UnsafeCell<i32>>(); | ^^^^^^^^^^^^^^^ `UnsafeCell<i32>` cannot be shared between threads safely | = help: the trait `Sync` is not implemented for `UnsafeCell<i32>`

This is the same UnsafeCell that the aliasing model turns on. There it is the one type permitted to be mutated through a shared reference, the single legal exception to the rule that &T means nobody is writing. Here it is the root of !Sync. Those are not two coincidental facts about one type. They are the same fact stated twice: a type that permits mutation through a shared reference is precisely a type that cannot be shared across threads without further synchronization.

That status is written down rather than worked out:

impl<T: ?Sized> !Sync for UnsafeCell<T> {}

Cell and RefCell would fail the walk anyway, since each contains an UnsafeCell, but the standard library does not lean on that. It declares both !Sync outright as well. Try to share one:

let c = Cell::new(0);
thread::scope(|s| {
    s.spawn(|| c.set(1));
    s.spawn(|| c.set(2));
});

The closures capture &Cell<i32>, which is Send only if Cell<i32> is Sync, which it is not. The data race that would result is rejected at compile time by a rule with no knowledge of data races in it — only of fields and of a short list of declarations.

Raw pointers are another root, declared both !Send and !Sync in core, and they propagate the same way:

struct Wrapper {
    p: *const u8,
}

fn main() {
    assert_send::<Wrapper>();  // E0277
}

A raw pointer carries no information about what it addresses, who else holds it, or what synchronizes access to it, so nothing useful could ever be derived from one. But that is the reasoning behind the declaration, not the compiler's reasoning at the point of use. At the point of use it is reading the !Send that core declares for *const T and propagating the result to anything holding one, exactly as it propagated Rc through Holder.

That list is short, it lives in the standard library, and you cannot add to it. Which is precisely why the opt-out shown later works the way it does: you cannot declare a negative, but you can embed a type that already carries one.

A static Must Be Sync

One consequence of that short list turns up in a place with no threads in it anywhere, and it is where most people meet Sync for the first time. A static lives for the whole program and every thread can reach it through a shared reference, so the language requires its type to be Sync:

use std::cell::Cell;

static COUNTER: Cell<i32> = Cell::new(0);
error[E0277]: `Cell<i32>` cannot be shared between threads safely --> src/main.rs:6:17 | 6 | static COUNTER: Cell<i32> = Cell::new(0); | ^^^^^^^^^ `Cell<i32>` cannot be shared between threads safely | = help: the trait `Sync` is not implemented for `Cell<i32>` = note: shared static variables must have a type that implements `Sync`

A strictly single-threaded program is rejected exactly as firmly as a threaded one, because the requirement attaches to the declaration rather than to anything the program does. It is also why interior mutability at global scope means reaching for a Mutex, an RwLock or an atomic.

The demand is specific to static. Neither of these is rejected, with the same Cell in both:

const START: Cell<i32> = Cell::new(0);

thread_local! {
    static LOCAL: Cell<i32> = Cell::new(7);
}

A const is substituted at each use site rather than stored once, so no two threads ever reach the same value. A thread_local runs its initializer once per thread, so each thread has a value of its own. Only the genuinely shared declaration needs the genuinely shared guarantee, which means the form of the declaration decides whether Sync is demanded — not the type you put inside it.

unsafe impl: Asserting What the Compiler Cannot Check

Inheriting a negative is not a life sentence. The raw-pointer wrapper from earlier is not Send because of what it contains, but a wrapper that genuinely is safe to send — because it owns its allocation exclusively, say — can say so:

struct Wrapper {
    p: *const u8,
}

unsafe impl Send for Wrapper {}

That compiles, and Wrapper is now Send by assertion rather than by derivation. The unsafe keyword is not decoration. Remove it and the compiler rejects the impl outright, with an unusually direct explanation:

error[E0200]: the trait `Send` requires an `unsafe impl` declaration --> src/main.rs:9:1 | 9 | impl Send for Wrapper {} | ^^^^^^^^^^^^^^^^^^^^^ | = note: the trait `Send` enforces invariants that the compiler can't check. Review the trait documentation and make sure this implementation upholds those invariants before adding the `unsafe` keyword help: add `unsafe` to this trait implementation | 9 | unsafe impl Send for Wrapper {} | ++++++

"Enforces invariants that the compiler can't check" is the whole justification for the design. Everywhere else in the language, unsafe marks a place where you take on a proof obligation the compiler cannot discharge. An unsafe impl Send is exactly that: you are asserting that moving this value across a thread boundary cannot introduce a data race, and nothing verifies it.

The standard library uses this constantly, and conditionally. Its declaration for Arc reads:

unsafe impl<T: ?Sized + Sync + Send, A: Allocator + Send> Send for Arc<T, A> {}

The assertion is made only when the contents justify it, which is why Arc<Cell<i32>> is not Send even though Arc<i32> is:

error[E0277]: `Cell<i32>` cannot be shared between threads safely --> src/main.rs:12:19 | 12 | assert_send::<Arc<Cell<i32>>>(); // rejected | ^^^^^^^^^^^^^^ `Cell<i32>` cannot be shared between threads safely | = help: the trait `Sync` is not implemented for `Cell<i32>` = note: required for `Arc<Cell<i32>>` to implement `Send`

The last note is the conditional assertion being checked rather than assumed. Arc<T> is Send only when T is both Send and Sync, and Cell fails the Sync half.

PhantomData: Opting Out on Stable

The reverse operation is stranger, because there is no stable syntax for "this type is not Send". What there is instead is a trick that follows from the derivation being structural: if you want a type to fail the field walk, give it a field that fails.

use std::marker::PhantomData;

struct NotSend {
    v: u32,
    _marker: PhantomData<*const ()>,
}

PhantomData occupies no space, but it counts as a field for the purposes of the walk, and a raw pointer is one of the roots listed earlier. So NotSend fails assert_send with the same E0277 that Wrapper did. You have not declared anything negative. You have arranged for the ordinary rule to produce the answer you wanted.

This is the same PhantomData that appears in the variance article, doing a structurally identical job on a different axis. There the choice of type parameter controls how the compiler's variance computation treats a type; here it controls how the auto trait computation treats it. One marker, two derivations, both of them driven purely by the shape of the fields.

Rust does have real negative impls, and they say what they mean:

#![feature(negative_impls)]

struct NoSend {
    v: u32,
}

impl !Send for NoSend {}

fn main() {
    assert_send::<NoSend>();
}

They are also unavailable. On nightly that example produces the expected E0277. On stable the feature gate fires as well, and it is reported first:

error[E0554]: `#![feature]` may not be used on the stable release channel --> src/main.rs:3:1 | 3 | #![feature(negative_impls)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^

This is the feature the standard library used to declare Rc !Send earlier on. The standard library is compiled with unstable features enabled and your crate is not, which is the whole asymmetry: std says what it means, and you arrange for a field to fail. So on stable a failing field is not merely the idiomatic way to opt out of an auto trait. It is the only way, and PhantomData is how you do it without paying for the field. Worth noting that the gate does not suppress the trait check: stable reports E0554 and the E0277 together, because the negative impl was still parsed and taken into account.

Unpin Is an Auto Trait Too

Send and Sync are about threads, which makes it easy to assume auto traits are a concurrency feature. They are not. Unpin is an auto trait too, and it has nothing to do with concurrency.

fn assert_unpin<T: Unpin>() {}

fn main() {
    assert_unpin::<Plain>();
    assert_unpin::<String>();
    assert_unpin::<Vec<u8>>();
}

All three pass. Ordinary types are Unpin for free, derived structurally, exactly like Send. And the opt-out is the same marker-field trick, with a purpose-built marker:

use std::marker::PhantomPinned;

struct SelfRef {
    v: u32,
    _pin: PhantomPinned,
}

fn main() {
    assert_unpin::<SelfRef>();
}
error[E0277]: `PhantomPinned` cannot be unpinned --> src/main.rs:14:20 | 14 | assert_unpin::<SelfRef>(); | ^^^^^^^ within `SelfRef`, the trait `Unpin` is not implemented for `PhantomPinned` | = note: consider using the `pin!` macro consider using `Box::pin` if you need to access the pinned value outside of the current scope note: required because it appears within the type `SelfRef`

The "within" clause is back, and it is the same walk. Pin is a large enough subject to deserve its own article, but the reason it works at all is visible from here: the default is permissive, so Pin<&mut T> imposes nothing on the overwhelming majority of types, and the small number that genuinely cannot be moved opt out structurally by containing a PhantomPinned.

One asymmetry separates Unpin from the other two. Send and Sync demand unsafe impl. Unpin does not. Add one line to SelfRef:

impl Unpin for SelfRef {}   // no unsafe keyword

It is accepted, and SelfRef is Unpin again despite the marker field. Implementing Unpin wrongly cannot by itself break memory safety, so the impl carries no proof obligation. The guarantee that Pin rests on is upheld by the unsafe code that constructs a pinned value, and that is where the obligation lives.

UnwindSafe: The Same Walk, for Panics

Unpin shows the machinery is not about threads. UnwindSafe shows it is not about moving values either. catch_unwind requires its closure to be UnwindSafe, and a closure holding a &mut is not:

let mut v = vec![1, 2, 3];

let _ = catch_unwind(|| {
    v.push(4);
});
error[E0277]: the type `&mut Vec<i32>` may not be safely transferred across an unwind boundary --> src/main.rs:9:26 | = help: within `{closure@src/main.rs:9:26: 9:28}`, the trait `UnwindSafe` is not implemented for `&mut Vec<i32>` = note: `UnwindSafe` is implemented for `&Vec<i32>`, but not for `&mut Vec<i32>` note: required because it's used within this closure

Three separate things are happening in that message. "within this closure" is the identical field walk over the identical captures. The property being derived has nothing to do with concurrency at all — it asks whether a panic could leave this value observably half-modified and then let you carry on using it. And the final note draws the same line the Send rules draw for shared references: &Vec<i32> qualifies where &mut Vec<i32> does not, because only one of the two lets the closure modify the value at all. A panic partway through a mutation leaves the owner holding something logically broken, and a shared reference gives the closure no way to bring that about.

Like Unpin, and unlike Send and Sync, UnwindSafe and its companion RefUnwindSafe are safe traits. An impl UnwindSafe needs no unsafe keyword, because the property is a promise about logical state after a panic, not about memory, and AssertUnwindSafe exists precisely to let you make that promise on someone else's type.

Closures Capture Fields, Not Structs

The walk applies to types the compiler generates as readily as to types you declare, and a closure is one of those. These two functions differ only in what the closure prints:

struct Holder {
    id: u32,
    shared: Rc<String>,
}

fn spawn_id(h: Holder) {
    thread::spawn(move || {
        println!("{}", h.id);
    });
}

fn spawn_both(h: Holder) {
    thread::spawn(move || {
        println!("{} {}", h.id, h.shared);
    });
}

The first compiles and the second does not. Holder is the same type in both, and the Rc is one of its fields either way. What differs is what the closure captured: since edition 2021 a closure captures individual fields rather than the whole struct, so spawn_id produced an anonymous type holding a u32, and spawn_both produced one holding a u32 and an Rc. The walk then runs over those captures, and the failure is reported against a type with no name:

error[E0277]: `Rc<String>` cannot be sent between threads safely --> src/main.rs:13:19 | = help: within `{closure@src/main.rs:13:19: 13:26}`, the trait `Send` is not implemented for `Rc<String>` note: required because it's used within this closure --> src/main.rs:13:19 | 13 | thread::spawn(move || { | ^^^^^^^

Moving one field's use into or out of a closure body therefore changes whether that closure can be spawned, without changing any type you wrote.

The capture mode counts for as much as the field. A closure implements an auto trait when the types of its captures do, and a capture taken by shared reference contributes &T rather than T. A closure that reads a Cell by reference is therefore asking whether Cell is Sync, while a move closure that takes the same Cell by value is asking whether it is Send. Those two questions have different answers, as the rules for &T and T established earlier.

Auto Traits Leak Through impl Trait

A closure at least has captures you can point at in the source. An async fn does not give you even that. Consider one with no trait bounds written anywhere in it. The helper changes shape here, because a future's type cannot be written down: this version takes the value and lets inference name the type.

fn assert_send<T: Send>(_: T) {}

async fn other() {}

async fn task() {
    let local = Rc::new(1);
    other().await;
    println!("{local}");
}

fn main() {
    assert_send(task());
}

This is rejected, and unlike the failures so far the diagnostic has no error code at all — it is a bespoke message the compiler reserves for this situation:

error: future cannot be sent between threads safely --> src/main.rs:17:17 | 17 | assert_send(task()); | ^^^^^^ future returned by `task` is not `Send` | = help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `Rc<i32>` note: future is not `Send` as this value is used across an await --> src/main.rs:12:13 | 11 | let local = Rc::new(1); | ----- has type `Rc<i32>` which is not `Send` 12 | other().await; | ^^^^^ await occurs here, with `local` maybe used later

The help line gives the game away: "within impl Future<Output = ()>". It is the identical phrasing from the Holder error, applied to a type that has no name and that you did not write. An async fn compiles to a state machine whose fields are the locals that must survive across an await, and auto traits are then derived over those fields by the ordinary rule. The relationship to the memory layout of futures is direct: the same field set that determines a future's size determines its thread safety.

This is what makes auto traits leak. The signature async fn task() says nothing about Send, yet the answer is fixed by the body, and callers depend on it. Spawning onto a work-stealing runtime requires a Send future, so introducing an Rc in the middle of a private function can break a spawn several layers away.

A Borrowed Local Stays Until Its Scope Ends

The obvious repair is to get rid of the value before the await, and it works:

async fn task() {
    let local = Rc::new(1);
    drop(local);
    other().await;
}

The future is Send. The move into drop ends the local as far as the state machine is concerned, and nothing of it survives the await. Now use the value before dropping it:

async fn task() {
    let local = Rc::new(1);
    println!("{local}");
    drop(local);        // moved away before the await
    other().await;
}

local has still been moved into drop and cannot possibly be touched afterwards. The future is no longer Send, and the compiler's phrasing is the tell:

note: future is not `Send` as this value is used across an await --> src/main.rs:15:13 | 12 | let local = Rc::new(1); | ----- has type `Rc<i32>` which is not `Send` ... 15 | other().await; | ^^^^^ await occurs here, with `local` maybe used later

"maybe used later" is the operative phrase. The only change is the println!, and println! takes its arguments by reference. Once a local has been borrowed, the analysis that decides which locals survive an await stops following moves out of it and keeps it until its scope ends. local is still in scope at the await, so the slot is captured, the field exists, and the walk finds an Rc. Anything that takes a reference counts, including a *local or a method call, since both go through &local. Confining the borrow to an inner block that has already closed does not help either:

async fn task() {
    let local = Rc::new(1);
    {
        let r = &local;
        println!("{r}");
    }
    drop(local);
    other().await;
}

That is rejected with the same "maybe used later". What does work is ending the scope of local itself:

async fn task() {
    {
        let local = Rc::new(1);
        println!("{local}");
    }
    other().await;
}

The future is Send again. None of this is visible in a type signature, and none of it follows from the rules stated so far. It comes from how the compiler decides which locals a state machine must keep: a move ends a local that was never borrowed, but a borrow pins the local to its scope. If you take one operational habit away from this article, it is that a non-Send value you have borrowed must go out of scope before the await, and the way to arrange that is a block, not a drop().

async fn in Traits Cannot Be Bounded

Leakage through a free function is a convenience, and a deliberate one. The compiler peeks at the hidden type, works out its auto traits, and lets the caller depend on them, so that an impl Trait return behaves like a struct wrapping a private field: whatever the field is, the auto traits show through. In code generic over a trait, that design runs out of road.

An async fn in a trait desugars to an associated type with no name. There is nothing for a caller to write a bound on, so generic code cannot require that the returned future is Send:

trait Fetcher {
    async fn fetch(&self) -> String;
}

fn use_it<F: Fetcher>(f: &F) {
    assert_send(f.fetch());
}
error[E0277]: `impl Future<Output = String>` cannot be sent between threads safely --> src/main.rs:12:17 | 12 | assert_send(f.fetch()); | ----------- ^^^^^^^^^ `impl Future<Output = String>` cannot be sent between threads safely | = help: the trait `Send` is not implemented for `impl Future<Output = String>` help: `Send` can be made part of the associated future's guarantees for all implementations of `Fetcher::fetch` | 6 - async fn fetch(&self) -> String; 6 + fn fetch(&self) -> impl std::future::Future<Output = String> + Send;

This is the send bound problem, and it explains a great deal of async Rust that looks as though it ought to be generic over a trait and is not. A work-stealing runtime needs a Send future, and the async fn sugar gives the trait no way to promise one.

The last block of that diagnostic is the compiler telling you to stop using async fn and write the desugaring out, because a hand-written return type is somewhere a bound can live:

trait Fetcher {
    fn fetch(&self) -> impl Future<Output = String> + Send;
}

Now the future is Send for every implementor by contract rather than by inference. The price is that the trait has now committed: anyone whose future is not Send can no longer implement it at all. The trait-variant crate exists to generate both flavors of a trait from one definition, and return type notation, which would let the caller write the bound at the use site instead of the trait author guessing, was still unstable when this was written.

Trait Objects Must Name Their Auto Traits

A trait method's future has no name to attach a bound to. A trait object has no fields to walk, which gets you to the same place by a different route. dyn Job has erased the concrete type, so there is nothing left to inspect and nothing to derive from. The auto traits are therefore not implied, and have to be written into the type:

trait Job {
    fn run(&self);
}

fn main() {
    assert_send::<Box<dyn Job + Send>>(); // named, fine
    assert_send::<Box<dyn Job>>(); // not named, rejected
}
error[E0277]: `dyn Job` cannot be sent between threads safely --> src/main.rs:14:19 | 14 | assert_send::<Box<dyn Job>>(); // not named, rejected | ^^^^^^^^^^^^ `dyn Job` cannot be sent between threads safely | = help: the trait `Send` is not implemented for `dyn Job` = note: required for `std::ptr::Unique<dyn Job>` to implement `Send` note: required because it appears within the type `Box<dyn Job>`

The notes are the walk running as far as it can. It descends into the Unique pointer that Box holds, meets the conditional impl that makes Unique<T> Send when T is, and stops at dyn Job, where there is nothing further to look at. This is why signatures across the ecosystem read Box<dyn Error + Send + Sync> rather than Box<dyn Error>. The bound cannot be inferred, so it has to be spelled out, and a trait object that forgot to name it cannot be retrofitted by a caller.

The syntax is itself a special case. A trait object may name only one non-auto trait, which is why Box<dyn Debug + Display> is rejected. Auto traits are exempt from that restriction and may be stacked on top freely, which is the only reason Box<dyn Debug + Send + UnwindSafe> is a legal type.

Defining Your Own Auto Trait

A reasonable reading of everything above is that the compiler carries a handful of hard-coded special cases. It does not. auto trait is a general construct, and on nightly you can declare one:

#![feature(auto_traits, negative_impls)]

auto trait Fizzy {}

struct Ordinary;

struct NotFizzy;
impl !Fizzy for NotFizzy {}

struct Container {
    inner: NotFizzy,
}

fn assert_fizzy<T: Fizzy>() {}

fn main() {
    assert_fizzy::<Ordinary>(); // auto-derived, fine
    assert_fizzy::<Container>(); // infected by NotFizzy
}

Fizzy means nothing. It has no methods, no semantics and no support anywhere in the standard library. It behaves identically anyway:

error[E0277]: the trait bound `NotFizzy: Fizzy` is not satisfied in `Container` --> src/main.rs:22:20 | 22 | assert_fizzy::<Container>(); // infected by NotFizzy | ^^^^^^^^^ unsatisfied trait bound | help: within `Container`, the trait `Fizzy` is not implemented for `NotFizzy` --> src/main.rs:11:1 | 11 | struct NotFizzy; | ^^^^^^^^^^^^^^^ note: required because it appears within the type `Container`

Same "within" clause, same "required because it appears within the type", same propagation from a field to its container. Nothing in the compiler knows that Send is about threads. It knows how to walk fields and how to honor an opt-out. Send, Sync, Unpin, UnwindSafe and RefUnwindSafe are all applications of that one machine.

The declaration is constrained in ways an ordinary trait is not. An auto trait may not have methods or associated types, because the compiler must be able to generate an impl for every type in the program without being told anything about it. It may not have supertraits either. And resolution is coinductive, meaning a cycle counts as a success rather than a failure — which is what lets a recursive type like a linked list be Send at all instead of sending the solver into infinite regress.

The feature has been unstable for years, and the blocking problem is sharper than a general complaint about coherence. Unrestricted negative impls turn an auto trait into a test for the absence of a type:

auto trait NoString {}
impl !NoString for String {}

A function bounded on NoString now succeeds or fails according to whether a String appears anywhere inside its argument, at any depth. That makes almost any change to almost any type a potential breaking change for every crate downstream. The language team's position has softened over time, and stabilizing some restricted form is no longer considered out of the question. What such a form might look like is already visible in pyo3: on nightly it defines its Ungil as an auto trait with negative impls confined to types it defines itself, and on stable it falls back to a blanket impl over Send. The general case remains open.

Private Fields Are Public API

That hazard needs nobody to define a new auto trait. It is already live for the ones you have, and it is easy to miss precisely because the impls are invisible.

A private field is part of your public API. This follows directly from the Holder case: adding a non-Send field to a struct, even a private one used purely as an implementation detail, removes Send from the public type and breaks every downstream caller who was spawning it onto a thread. Nothing in the type's signature changed, and nothing in your crate's documentation records the guarantee you just withdrew.

The same hazard applies to async fn, with a smaller blast radius but a much larger surface, since any local that crosses an await participates. A refactor entirely inside a function body can change whether callers can spawn the result.

Three habits follow. Prefer making a type genuinely satisfy the derivation over asserting it with unsafe impl. When you do write an unsafe impl, write down the invariant you are claiming, because the compiler has recorded nothing. And keep non-Send values out of scopes that span an await: once such a value has been borrowed, only a block that ends before the await removes it, and a drop() does not.

What Is Settled and What Is Not

Most of what is above is language semantics that has not moved in years and is not going to: the field walk itself, the definitions of Send and Sync, the rules for shared and exclusive references, the requirement on static, unsafe impl, the marker-field opt-out, and the fact that a private field is part of your public API. Those can be relied on.

Four things here are snapshots rather than fixtures, all taken at Rust 1.96 in 2026.

Nothing in the first paragraph depends on anything in the list.

Key Takeaways

Further Reading