When unsafe is justified, and what you owe when you use it
The most common misuse of unsafe in Rust is treating it as an escape hatch from the borrow checker.
It is not. It does not disable borrow checking, it does not turn off the type system, and it does not
make a design that fights the compiler suddenly correct.
What it does is unlock five operations, and take responsibility for their preconditions away from the compiler and give it to you.
The five things it enables
- Dereferencing a raw pointer
- Calling an
unsafefunction, including any FFI - Accessing or modifying a mutable
static - Implementing an
unsafetrait (Send,Sync) - Accessing a field of a
union
That is the whole list. Everything else in the language works identically inside an unsafe block —
borrows are still checked, types are still enforced, lifetimes still apply.
If your error is “cannot borrow as mutable more than once”, unsafe will not help. The compiler is
describing a real aliasing problem, and wrapping it changes nothing.
The three legitimate reasons
Foreign function interface. Calling C, or being called by C. The compiler cannot verify a contract that lives in another language, so somebody has to, and that somebody is you.
extern "C" {
fn compute_checksum(data: *const u8, length: usize) -> u32;
}
pub fn checksum(data: &[u8]) -> u32 {
// SAFETY: `data` is a valid slice, so the pointer is non-null, aligned,
// and valid for `len` bytes. `compute_checksum` does not retain the pointer.
unsafe { compute_checksum(data.as_ptr(), data.len()) }
}
Building an abstraction the borrow checker cannot express. Vec, RefCell, Rc and Mutex
are all implemented with unsafe internally. They exist to provide a safe API over an operation
the checker cannot prove correct.
pub fn split_at_mut(&mut self, index: usize) -> (&mut [T], &mut [T]) {
// two mutable references into one slice — the checker cannot see they are disjoint
}
Measured, necessary performance. Skipping a bounds check in a loop that a profiler has shown to be hot. This is the rarest legitimate reason and the most commonly claimed one.
What you owe: the safety comment
Every unsafe block should carry a comment stating why it is sound — what invariants hold, and
who guarantees them.
// SAFETY: `index` is checked against `self.len` above, so the offset is within
// the allocation. `self.pointer` is non-null and aligned for the lifetime of `self`
// because it comes from a successful `alloc` in `with_capacity`.
unsafe { *self.pointer.add(index) }
This is not documentation politeness. The compiler has stopped checking; the comment is the only remaining record of what makes it correct. Without it, the next person — including you in six months — cannot verify or safely modify the code, because the reasoning that made it sound is gone.
Warning
Enforce it. #![deny(clippy::undocumented_unsafe_blocks)] makes a missing safety comment a build
failure. It is one line and it is the highest-value lint in the language.
Keep it small and wrapped
Two rules that limit the damage.
The block covers the operation, not the function. An unsafe fn that is a hundred lines is a
hundred lines you must audit. A three-line block inside a safe function is three.
A safe API on top. The point of unsafe is to be contained. If callers must uphold an invariant
to use your function safely, the function must be unsafe fn and say so — moving the burden outward
without marking it is how a soundness bug spreads through a codebase.
// Wrong: safe signature, unsafe requirement
pub fn get_unchecked(&self, index: usize) -> &T { … }
// Right: the signature says the caller has an obligation
pub unsafe fn get_unchecked(&self, index: usize) -> &T { … }
The tools that check what the compiler cannot
Miri interprets your code and detects undefined behaviour — out-of-bounds access, use-after-free, invalid alignment, data races:
cargo +nightly miri test
It is slow and it finds real bugs that all your tests pass through. Any crate with unsafe should
run it in CI.
AddressSanitizer for FFI, where the problem is usually on the C side:
RUSTFLAGS="-Z sanitizer=address" cargo +nightly test
The question to ask first
Before writing unsafe, ask: has someone already wrapped this?
The answer is usually yes. bytemuck for safe transmutes, zerocopy for byte-level conversions,
crossbeam for lock-free structures, parking_lot for locks, slotmap for arena patterns that
would otherwise need raw pointers.
Using a well-tested crate that contains unsafe is entirely different from writing your own. Theirs
has been reviewed, fuzzed and run under Miri by people who specialise in exactly this. Mine has been
read by me, once, on the day I wrote it.
That asymmetry is the real argument. The cases where unsafe in application code is justified are
rare, and the cases where it is necessary — because nobody has built the abstraction yet — are
rarer still.