Home

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

  1. Dereferencing a raw pointer
  2. Calling an unsafe function, including any FFI
  3. Accessing or modifying a mutable static
  4. Implementing an unsafe trait (Send, Sync)
  5. 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.