Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

When the Type System Is Wrong and You’re Right

There are cases — rare, but real — where the type system is rejecting code that is genuinely correct. The data structures are sound. The lifetimes are valid. The compiler simply cannot prove it. For these cases, Rust provides escape hatches: unsafe, raw pointers, UnsafeCell, transmute, MaybeUninit. They exist because the alternative — making the borrow checker omniscient — is impossible.

This chapter is about using those tools without breaking your program. It is also about being honest with yourself about when you have entered this category and when you have merely lost patience.

The categories of unsafe

unsafe in Rust is not “turn off the type checker.” It is “I am taking responsibility for upholding invariants the compiler cannot check.” There are five operations that unsafe enables:

  1. Dereferencing raw pointers (*const T, *mut T).
  2. Calling unsafe functions (functions whose contract goes beyond their type signature).
  3. Implementing unsafe traits (traits like Send, Sync, or others where wrong implementations cause undefined behavior elsewhere).
  4. Mutating mutable static variables.
  5. Accessing fields of union types.

Most working unsafe code uses (1) and (2). The other three are specialized.

The crucial point: unsafe does not weaken Rust’s type system. It just gives you primitives that, used correctly, are sound — and used incorrectly, are undefined behavior. The boundary between safe and unsafe is the boundary between “the compiler proves correctness” and “the human proves correctness.” Both are valid. The latter is more dangerous.

The genuine cases for unsafe

Here are the cases where reaching for unsafe is the right call, ordered roughly by frequency.

FFI. Every call across an FFI boundary is unsafe, because the compiler can’t see the other side. extern "C" functions, libc calls, bindings to C++ libraries — all unsafe by necessity. The Rust side wraps the unsafe interface in a safe one, and the safe wrapper is where the soundness reasoning lives.

Low-level data structures with intrusive pointers. Doubly-linked lists, skip lists, lock-free queues, intrusive containers. These have aliasing patterns that the borrow checker categorically cannot prove safe. The standard library’s LinkedList, BTreeMap internals, and several others use unsafe. The pattern: the data structure is internally unsafe; the public API is safe.

Self-referential structures. Sometimes you genuinely need a struct that contains a reference to its own data, in a way that goes beyond what Pin and async desugaring can express. Rare in application code; common in parser combinators, certain async runtimes, and some database internals. The ouroboros crate provides a macro for the common cases; for unusual cases, you write the unsafe yourself.

Optimization that the safe version can’t express. Vec::set_len is unsafe because it bypasses initialization tracking. unreachable_unchecked is unsafe because it tells the compiler an enum variant is impossible. slice::get_unchecked is unsafe because it skips bounds checking. Each of these has a safe counterpart that’s slightly slower; you reach for the unsafe version when profiling shows the safety check is the bottleneck.

Custom synchronization primitives. Implementing your own Mutex, RwLock, channel, or atomic algorithm requires unsafe because the safe primitives are too high-level to compose. Don’t do this unless you’ve read The Art of Multiprocessor Programming and have a clear reason the existing primitives don’t work.

That’s most of it. Anything not in this list — and especially anything where the motivation is “the borrow checker won’t let me do X” — should be examined closely. The borrow checker is right more often than not.

The tools

A whirlwind tour of the unsafe primitives:

Raw pointers (*const T, *mut T). Like C pointers. No lifetime, no aliasing rules, no automatic anything. You can have many *mut T to the same data; you can dereference them; you can pass them around. The compiler’s job ends at the pointer creation. The dereferencing is unsafe.

#![allow(unused)]
fn main() {
let mut x = 5;
let p: *mut i32 = &mut x;
unsafe { *p = 10; }
assert_eq!(x, 10);
}

UnsafeCell<T>. The only legal way to mutate through a &T. Every interior mutability primitive (Cell, RefCell, Mutex, RwLock, AtomicXxx) is built on UnsafeCell. Using it directly is rare in application code but unavoidable in custom synchronization primitives.

#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;

struct MyCell<T>(UnsafeCell<T>);

impl<T> MyCell<T> {
    fn set(&self, value: T) {
        unsafe { *self.0.get() = value; }
    }
}
}

MaybeUninit<T>. Lets you have a T-sized hole in memory that hasn’t been initialized yet. The right way to write code that allocates a buffer and fills it incrementally without zero-initializing. Replaces an old, incorrect pattern of std::mem::uninitialized (which was deprecated because it was unsound for almost every type).

#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;

let mut buf: [MaybeUninit<u8>; 1024] = [MaybeUninit::uninit(); 1024];
for slot in &mut buf[..512] {
    slot.write(0);
}
let initialized: &[u8] = unsafe {
    std::slice::from_raw_parts(buf.as_ptr() as *const u8, 512)
};
}

transmute. Reinterpret one type as another. The most dangerous primitive in the standard library. Usually wrong; usually has a safer alternative (as casts, from_ne_bytes, bytemuck). Use only when you’ve ruled out everything else.

std::ptr::read, write, copy, copy_nonoverlapping. Primitive memory operations. copy_nonoverlapping is the safe version of memcpy (with the contract that source and destination don’t overlap); copy allows overlap. Both are unsafe because the source must be initialized and the destination must be valid.

The aliasing model

The single most important thing to internalize about unsafe Rust is the aliasing model. Rust’s references — &T and &mut T — make strong promises about aliasing:

  • A &T promises the data won’t be mutated through any other reference for 'a.
  • A &mut T promises the data won’t be accessed through any other reference for 'a.

These promises let the compiler optimize aggressively. They are also part of the language contract, and violating them via raw pointers is undefined behavior even if the resulting program “looks like it works.”

In particular: you cannot create two &mut T to the same data, even via raw pointers, and then use them concurrently. Even if both happen to write the same value. Even if you only read from both. The aliasing rules are about types, not behaviors. &mut T aliasing another reference to the same data is UB, full stop.

This rule is enforced by Stacked Borrows or, more recently, Tree Borrows — formal models of Rust’s aliasing rules that Miri implements. If your unsafe code violates the model, Miri will catch it. If it doesn’t catch it today, a future compiler optimization may rely on the model and your code will start producing wrong results.

The practical consequence: when you use raw pointers, do not interleave them with references. Convert to a raw pointer, do all your raw-pointer work, then convert back. Don’t have a &mut T and a *mut T to the same data alive at the same time.

Miri as the safety net

Miri is an interpreter for Rust’s intermediate representation that implements the formal aliasing model and checks for undefined behavior at runtime. If you write unsafe code, run your tests under Miri:

cargo +nightly miri test

Miri catches:

  • Use-after-free.
  • Out-of-bounds memory access.
  • Aliasing violations (Stacked/Tree Borrows).
  • Reading uninitialized memory.
  • Misaligned pointer access.
  • Data races (in some configurations).

Miri does not catch:

  • Bugs that don’t trigger UB (logic errors, race conditions that don’t violate aliasing).
  • Bugs in code that isn’t exercised by your tests.
  • Soundness issues that depend on optimization (Miri doesn’t optimize).

The discipline: every unsafe block in your codebase should be exercised by a test that runs under Miri. If Miri passes, you have strong evidence (not proof) that the code is sound. If Miri fails, you have a bug.

Writing safe wrappers

The standard pattern for unsafe code is: the unsafe operations live inside a struct’s implementation, and the struct’s public API is safe. The struct’s invariants are documented; as long as the invariants hold, the unsafe operations are sound.

#![allow(unused)]
fn main() {
pub struct RawBuf {
    ptr: *mut u8,
    capacity: usize,
}

impl RawBuf {
    pub fn with_capacity(cap: usize) -> Self {
        let layout = std::alloc::Layout::array::<u8>(cap).unwrap();
        let ptr = unsafe { std::alloc::alloc(layout) };
        if ptr.is_null() { std::alloc::handle_alloc_error(layout); }
        RawBuf { ptr, capacity: cap }
    }

    pub fn write(&mut self, idx: usize, byte: u8) {
        assert!(idx < self.capacity);
        unsafe { *self.ptr.add(idx) = byte; }
    }

    pub fn read(&self, idx: usize) -> u8 {
        assert!(idx < self.capacity);
        unsafe { *self.ptr.add(idx) }
    }
}

impl Drop for RawBuf {
    fn drop(&mut self) {
        let layout = std::alloc::Layout::array::<u8>(self.capacity).unwrap();
        unsafe { std::alloc::dealloc(self.ptr, layout); }
    }
}
}

The invariants:

  • ptr is a valid pointer to capacity bytes of allocated memory, until drop.
  • ptr is unique (no other RawBuf shares it; no aliasing issues).
  • capacity is the same value passed to alloc and used in dealloc.

These invariants are documented (mentally; in real code, they would be in a # Safety comment). The unsafe blocks are sound because the invariants hold. The public API enforces the invariants — there is no way for safe user code to set ptr to garbage, or to mismatch capacity.

This is what good unsafe Rust looks like. The unsafe is local. The invariants are explicit. The abstraction is sound. Users of the API never see unsafe and never have to think about aliasing.

When unsafe is wrong

Categorical no-no’s:

  • Using transmute to convert between unrelated types. Almost always wrong. Use as casts, from_ne_bytes, or bytemuck.
  • Using unsafe impl Send for X {} to “just make it work.” Either X is genuinely Send (in which case prove it and document why) or it isn’t (in which case fix the design).
  • Using raw pointers to share mutable state across threads. This is undefined behavior and exists nowhere on the safe path. Use Mutex, RwLock, atomics, or channels.
  • Calling unsafe { unreachable_unchecked() } because you “know” the case is impossible. Use a regular unreachable!(). The unchecked version is for cases where the compiler can prove the impossibility based on prior assertions; it is not a tool for human optimism.
  • Box::leak to satisfy a 'static bound. Leaking memory to make types fit is almost always the wrong abstraction. Restructure ownership instead.

If you find yourself reaching for unsafe and it would fall into one of these categories, stop. The fix is in the safe code, not in the unsafe escape.

The mental model for unsafe

The right mental model is this: unsafe is a contract between you and the compiler. The compiler says, “I cannot prove this is sound. Will you?” You say “yes, here is why,” and the unsafe block records your promise. If your promise is false, the program is incorrect — not because the compiler missed something, but because you did.

This contract has a few implications:

  • unsafe blocks should be small. The smaller the unsafe, the smaller the proof obligation. A function that is mostly safe code with a single unsafe line is easier to audit than a function that is unsafe fn end-to-end.
  • unsafe blocks should have safety comments. A // SAFETY: ... comment explains why the unsafe is sound. If you can’t write the comment, you don’t yet know whether it’s sound.
  • unsafe blocks should be tested. Unit tests, fuzz tests, and Miri runs. The compiler doesn’t help you here; tests are your only feedback.
  • unsafe blocks should be reviewed. By you, by a colleague, by anyone. Code review is more important for unsafe than for any other kind of code.

Most engineers should write very little unsafe Rust. Most working Rust codebases have very little unsafe. The places where unsafe is appropriate are mostly libraries, and the libraries that use it well have spent significant attention on making sure they use it correctly.

If you have read this chapter and your reaction is “I’m going to add some unsafe to my project to fix that lifetime error,” reread the previous chapter and try the safe fix again. If you have read this chapter and your reaction is “okay, that’s the model, now I know what I’m taking on when I write unsafe,” you’re in the right place.

Sources

  • The Rustonomicon — the canonical reference for unsafe Rust. Read it if you write unsafe.
  • Miri’s documentation.
  • The Stacked Borrows paper by Ralf Jung, for the underlying aliasing model.
  • The bytemuck crate for safe transmutation.
  • The pin-project crate for safe pin projection.
  • Aria Beingessner’s posts on unsafe Rust, particularly the one on writing your own Vec.

This is the last chapter. The book ends here. Or rather, the book ends at the bibliography, which has the resources you should read next, because no single book on this material is enough.