8 min read #rust #unsafe-rust
On this page

unsafe Blocks and the Soundness Contract

The unsafe keyword does not "disable" safety checks; rather, it transfers the safety contract that the compiler normally guarantees to the programmer. You can dereference raw pointers, call unsafe functions, and access mutable static variables, but you must ensure that no matter what the caller does, it will not trigger UB (Undefined Behavior). The real difficulty in writing unsafe code is not "being able to write it," but rather wrapping it in a safe abstraction.

unsafe is Not "Disabling All Safety Checks"

An unsafe {} block enables only 5 capabilities; all other Rust safety checks remain in effect:

  1. Dereferencing raw pointers (*const T, *mut T)
  2. Calling unsafe fn
  3. Accessing or modifying static mut variables
  4. Implementing unsafe traits (e.g., Send, Sync, GlobalAlloc)
  5. Accessing fields of a union

The borrow checker remains active within unsafe blocks — you cannot create two &mut T references pointing to the same data. This is UB, and the compiler will still reject it even within unsafe code.

The key to understanding unsafe: it is not "I take on all safety responsibilities," but rather "I take on the responsibility for the part that the compiler cannot automatically verify."

Soundness: The Boundary of Safety

The signature of an unsafe fn defines a contract. The caller must satisfy the preconditions, otherwise UB may occur. This pattern is ubiquitous in the standard library:

// Safe externally, unsafe internally — the caller only needs to pass correct arguments, no need to write unsafe
pub fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) {
    assert!(mid <= self.len());      // Runtime check — this is the safety boundary!
    let ptr = self.as_mut_ptr();
    unsafe { (from_raw_parts_mut(ptr, mid),
              from_raw_parts_mut(ptr.add(mid), self.len() - mid)) }
}

If the assertion passes, the subsequent unsafe operations are sound — mid <= len guarantees that the two slices do not overlap. If the assertion fails, it panics directly — it does not enter the unsafe region. This is the most common pattern in the standard library: safe code validates conditions, and unsafe code executes under the premise that those conditions hold.

Crucially, the soundness of unsafe code depends not only on the logic within the unsafe block but also on the invariants maintained by external safe code. If the unsafe code you write assumes certain invariants of a data structure (such as len <= cap), then all external safe code that modifies that data structure must maintain that invariant.

UB (Undefined Behavior): The List to Avoid

  • Dereferencing null, dangling, or misaligned pointers
  • Violating borrowing rules (even within unsafe code)
  • Data races
  • Calling functions with the wrong ABI
  • Creating invalid values: bool that is neither 0 nor 1, null references, char that is a surrogate
  • Unwinding across FFI boundaries (panicking within an extern "C" function)

Miri: Automated UB Detection

Miri interprets Rust at the MIR level, tracking provenance and borrow states:

cargo +nightly miri test

It can detect use-after-free, out-of-bounds access, data races, and alignment violations. Any crate containing unsafe should be run with Miri — while it cannot prove soundness, it can catch obvious UB.

References

  • Rustonomicon: "What Unsafe Can Do", Soundness requirements
  • Rust Reference: Behavior considered undefined
  • Miri: github.com/rust-lang/miri

Keywords: unsafe, soundness, UB, Miri, safety contract, invariant, raw pointer