← Rust: Zero to Depth
Full-stack DevRust: Zero to Depth

unsafe: Five Powers, One Door, and a Counterintuitive Fact

Part 11 of the Rust: Zero to Depth series: the counterintuitive fact that unsafe does not disable the borrow checker, the five exact superpowers and their uses, the contract difference between raw pointers and references, how to draw a safe-abstraction boundary with SAFETY comments, and why seasoned Rustaceans write less and less unsafe.

RustunsafeFFISafe AbstractionRaw Pointers

For ten parts, the compiler was the all-powerful prover: memory safety, data races, lifetimes — all machine-provable. But the world contains truths it cannot prove: the address conventions of a hardware register, the promises in a C library’s documentation, the hand-reasoned “these two regions never overlap” inside a custom data structure. Rust’s answer to such truths is not pretending they don’t exist; it’s a clearly priced door: unsafe. The door is profoundly misunderstood — it does not “turn off safety checks.” It says: for these five acts, the proof is handwritten by you from now on. This part takes the door apart: what unsafe actually is, the five exact powers, how to draw the boundary, and why experts write less and less of it.

The most counterintuitive fact: unsafe does not disable the borrow checker

First, the load-bearing wall of this part. The following code sits inside an unsafe block — and the compiler intervenes as always:

unsafe {
    let s = String::from("hi");
    let r = &s;
    drop(s);            // error[E0505]: cannot move out of `s` because it is borrowed
    println!("{r}");
}

Ownership, moves, borrow rules, lifetimes, Dropevery check from the previous ten parts runs inside unsafe blocks, unchanged, not one missing. What unsafe changes is only “five additional things are permitted.” Thinking of it as “safety mode off” is the most popular wrong mental model; the correct one: where the compiler’s proof can’t reach, you’re allowed to substitute a manual proof — but the machine-proven territory doesn’t yield an inch.

Exactly five powers

  1. Dereference raw pointers. *const T / *mut T are references with the contract torn off: they may be null, may dangle, may alias freely, and carry no lifetime tracking. Creating a raw pointer is safe — danger materializes only at the dereference, so only that moment requires unsafe:
let mut n = 5u32;
let r = &mut n as *mut u32;   // safe: just made an address
unsafe {
    *r += 1;                  // unsafe: from here, validity is your guarantee
}
println!("{n}");              // 6
  1. Call unsafe functions — including FFI. Calling C is the most common case. The extern block declares signatures (the 2024 edition requires unsafe extern, because the signature itself is a promise you make on the compiler’s behalf), and the call site wraps it in an unsafe block:
unsafe extern "C" { fn abs(input: i32) -> i32; }

let v = unsafe { abs(-42) };  // the compiler can't check C's homework — it trusts you
  1. Implement unsafe traits. Part 9’s Send/Sync are unsafe traits — implementing one by hand is a signed affidavit that “this type is safe across threads; the reasoning lives in my head.” The rarest of the five; most types ride the auto derive forever.
  2. Access fields of unions. Unions exist mainly for C interop; the compiler can’t know which field is “currently active,” so reading one is your responsibility.
  3. Read or write mutable statics. Global mutable state is the eternal minefield of multithreading; the 2024 edition tightens further, warning even on references to a static mut. The modern spelling is almost always Mutex/OnceLock and friends — this power is increasingly a historical relic.

Safe abstractions: keeping unsafe behind a door

Taken in isolation, each of the five powers can produce undefined behavior — UB, the abyss where “the program may do anything, including appear to work fine.” Rust’s answer isn’t prohibition but encapsulation: lock small pieces of unsafe inside functions with safe signatures, so callers never touch it. That’s the “safe abstraction,” and it’s how the entire standard library is built — Vec, String, Part 9’s split_at_mut all have unsafe cores behind watertight safe doors.

Draw the boundary once yourself. Implement “split a slice into first element and rest” — two &muts into disjoint regions of one slice; the borrow checker can’t prove “non-overlapping,” but you can:

fn first_and_rest_mut<T>(slice: &mut [T]) -> Option<(&mut T, &mut [T])> {
    if slice.is_empty() { return None; }
    let ptr = slice.as_mut_ptr();
    let len = slice.len();
    unsafe {
        // SAFETY: ptr points at the slice's first element; [0..1] and [1..len]
        // never overlap, and both borrows derive from the same &mut [T]
        Some((&mut *ptr, std::slice::from_raw_parts_mut(ptr.add(1), len - 1)))
    }
}

let mut v = vec![1, 2, 3];
let (first, rest) = first_and_rest_mut(&mut v).unwrap();
*first += 10;
rest[0] += 100;
println!("{v:?}");  // [11, 102, 3]

Three points make up the craft:

  • Minimize behind the door. The unsafe block wraps only the necessary two or three lines; everything before and after is safe code — the audit surface is those lines.
  • The // SAFETY: comment is a mandatory convention. Above every unsafe, write why the manual proof holds. It isn’t ceremony: if you can’t write the comment, the proof usually doesn’t hold.
  • The signature must be watertight. The entire meaning of a safe abstraction: no legal input from any caller can produce UB. Returning None on the empty slice is part of that duty — a promise the signature makes to the world, not an implementation detail.

When to write it, when to run

The write signals are clear: FFI boundaries, hardware and kernel interfaces, and the last mile of performance where a profiler has testified that the safe spelling is the bottleneck. The run signal is simpler: the ecosystem already solved it — FFI has libc and bindgen, byte work has bytes, concurrency has tokio, parsing has nom. A mature ecosystem means someone already drew those boundaries for you and got them audited by a million users.

After writing unsafe, the last link of the verification toolchain is Miri — Rust’s UB interpreter, which catches dangling pointers, out-of-bounds access, data races, and other undefined behavior at test time. Unsafe code that hasn’t been through Miri is like a parachute jump without checking the pack.

Practice, then Part 12

  1. Verify the load-bearing wall by hand: type out this part’s E0505 example and compile it inside and outside an unsafe block. Then write one comment explaining it — for a new teammate, or for you in three months. It’s the fact about unsafe most worth remembering.
  2. Give first_and_rest_mut three tests: empty slice, single element, multiple elements. Then install Miri (rustup +nightly component add miri) and run them — experience the feeling of “UB caught by a machine.”
  3. Call one more C library function via unsafe extern (say, strlen) and write a safe wrapper: fn c_strlen(s: &CStr) -> usize. Notice how the wrapper turns a “trust the documentation” call into a “trust the type” call — that’s what a safe abstraction looks like on an FFI boundary.

Part 12 picks up a completely different weapon: macros. Why the hygiene of macro_rules! declarative macros matters, the true faces of vec! and println!, what each of the three procedural-macro brothers (derive, attribute, function-like) solves, and the strict criteria for “when should I write a macro” — the one feature in this series where you first learn when not to use it.

guest@swangnice:~$