Safe Rust is one of the strongest correctness guarantees a systems language has ever shipped. If your code compiles without unsafe blocks, the compiler has verified the absence of dangling references, data races, and undefined behavior from memory operations. That is a remarkable claim, and it holds. The problem is that “safe” and “correct” are not synonyms, and the edge between them matters enormously for anyone building production systems on Rust.
The gap has always existed. Async Rust just made it impossible to ignore.
What the Safe Guarantee Actually Covers
The Rust Reference defines safety precisely: safe code cannot cause undefined behavior. That covers memory unsafety (use-after-free, out-of-bounds access, uninitialized reads), data races, and invalid values in types. The unsafe keyword marks code where the programmer must manually uphold invariants the compiler cannot check.
What safety does not cover: logic errors, deadlocks, panics, violated protocol invariants, and semantic incorrectness. A Vec<T> method can be fully safe and still return the wrong answer. A Mutex can be fully safe and still deadlock. Safe code can leak memory indefinitely through reference cycles in Arc<T> without triggering any compiler warning, because Rust explicitly allows memory leaks as safe behavior.
This is not a flaw. It is a deliberate scope decision. The guarantee covers what the hardware will do with your program. It does not cover what your program will mean.
The Unsafe Abstraction Pattern
Most of the Rust standard library and most of the ecosystem follows what you could call the unsafe abstraction pattern: the public API is entirely safe to call, but the implementation contains unsafe blocks that require the author to manually prove correctness. Vec<T>, Arc<T>, Mutex<T>, HashMap<K, V> all work this way.
// Safe to call. The implementation of push uses unsafe internally
// to manage raw pointer arithmetic and allocation.
let mut v: Vec<u8> = Vec::new();
v.push(42);
The burden this places on library authors is real and underappreciated. Every unsafe block in a library is a proof obligation. The author must argue, informally or through tools like Miri, that no safe usage of the public API can trigger undefined behavior. If that argument is wrong, the safety guarantee breaks for all callers, even code that never writes unsafe itself.
The Rustnomicon puts this well: writing unsafe code is not just about getting your own code right. It is about ensuring that no sequence of safe operations by any caller can violate the invariants your unsafe blocks depend on. That is a much harder problem.
Pin and the Self-Referential Problem
Async Rust brought a new kind of edge case that required an entirely new type to express. When the compiler desugars an async fn, it generates a state machine that captures the local variables live across each .await point. Those variables may include references to other locals within the same struct. The resulting type is self-referential.
Moving a self-referential struct invalidates the internal references. Rust’s ownership system normally prevents this by making types moveable by default; you can always take a T and transfer it to a different memory location. For async state machines, that freedom is unsound.
Pin<T> was introduced in Rust 1.33 to solve this. A value wrapped in Pin cannot be moved unless it implements the Unpin auto-trait. The Future::poll method takes Pin<&mut Self> precisely because the async state machine may contain self-referential data.
// Calling poll requires a pinned mutable reference.
// This prevents the future from being moved between polls,
// which would invalidate any internal self-references.
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Here is where the edge gets sharp: constructing a Pin<&mut T> for a type that is not Unpin requires unsafe. The pin! macro, stabilized in Rust 1.68, handles the common case safely, but implementing a custom future that holds self-referential data puts you squarely in unsafe territory. The compiler cannot verify your Pin usage; you can construct a Pin<&mut T> through unsafe and then immediately move the underlying value, breaking the invariant the entire Pin API depends on.
Cancel Safety: The Semantic Hole
The most practically important edge for Tokio users is cancel safety, and it has nothing to do with memory unsafety at all. When you drop a Future before it completes, the async runtime simply stops polling it. No destructor runs in the middle of an async operation. The future’s state is cleaned up by Drop. This is sound by Rust’s definition.
The problem is semantic. Consider reading from a network socket:
tokio::select! {
line = reader.read_line(&mut buf) => { /* handle line */ }
_ = shutdown_signal() => { return; }
}
If shutdown_signal() completes first, the read_line future is dropped. If read_line had partially read bytes into buf, those bytes are now in the buffer but buf contains an incomplete line. The next call to read_line will append to that partial data, producing garbage. The compiler has no opinion about this. No unsafe was involved. The code compiles cleanly and silently has a bug that will only manifest under specific timing conditions.
Tokio’s documentation defines cancel safety as a property of each method: an operation is cancel-safe if it can be dropped and restarted without corrupting state. tokio::io::AsyncReadExt::read_line is not cancel-safe. tokio::sync::mpsc::Receiver::recv is. The distinction is semantic, not syntactic, and the only enforcement mechanism is documentation and programmer discipline.
This is not a criticism of Tokio’s design. The cancellation model Tokio uses gives exceptional control over task lifecycle without runtime overhead. The point is that the safety guarantee you get from the compiler does not extend to these semantic invariants. You are on your own, and a type system that could express cancel-safety constraints does not yet exist in stable Rust.
The Honor System for Send and Sync
Another edge involves the Send and Sync marker traits, which encode thread-safety properties. Send means a type can be moved to another thread. Sync means a shared reference to the type can be sent to another thread. Most types get their Send and Sync implementations automatically through the compiler’s auto-trait inference.
But you can override this. And overriding it requires unsafe:
// Claiming this type is safe to send across threads.
// The compiler cannot verify this claim.
unsafe impl Send for MyType {}
The entire thread-safety story in Rust rests on the correctness of these implementations. If you write unsafe impl Send for a type that contains a raw pointer to non-thread-safe data, you have silently broken the guarantee for everyone who uses your type. The compiler will accept it. Miri may not catch it depending on how the type is exercised. The bug may surface only under load in production.
FFI wrappers around C libraries are the common case where this comes up. Many C APIs are not thread-safe. The correct Rust wrapper for such an API should not implement Send or Sync. But implementing an incorrect wrapper is syntactically easy and only requires knowing when to write unsafe impl.
Where the Ecosystem Is Going
The community is actively working on making more of these implicit invariants explicit. The unsafe fields RFC would allow individual struct fields to be marked unsafe, making it possible to express that certain field accesses require upholding invariants the type system cannot check. This closes one specific gap: right now, a struct with an internal invariant that spans multiple fields has no way to communicate that reading or writing individual fields is unsafe without making the entire struct opaque.
Miri has become the standard tool for catching unsafe code violations during testing. It instruments memory operations and detects violations of the Stacked Borrows model, which is Rust’s operational semantics for pointer aliasing. It will not catch cancel-safety bugs or incorrect Send implementations at compile time, but it catches a large class of unsafe code errors that neither the compiler nor sanitizers reliably find.
The long-term direction is toward more expressive types. Effect systems, linear types for protocol state, and richer lifetime annotations have all been discussed as ways to push more semantic invariants into the type system. None of these are close to stable Rust. In the meantime, the edge of safe Rust is where good documentation, careful API design, and thorough testing have to carry the load the compiler cannot.
The boundary is real, it is well-defined, and it is narrower than it used to be. Async Rust has done the valuable work of making the remaining gaps visible. That visibility is, itself, progress.