Unsafe Rust Basics
unsafe does not turn off the borrow checker, disable type checking, or make Rust behave like C. Every safety check this series has relied on, ownership, lifetimes, exhaustiveness, still applies inside an unsafe block. What unsafe actually does is unlock five specific capabilities the compiler can't verify the safety of on its own, and shift the responsibility for upholding the invariants those capabilities depend on from the compiler to you. Most production Rust never needs to write unsafe directly, this tutorial is about recognizing precisely what it does, not encouraging reaching for it.
The Five Things unsafe Unlocks
An unsafe block (or function) is the only place you're allowed to:
- Dereference a raw pointer (
*const T/*mut T) - Call a function marked
unsafe fn - Implement an
unsafe trait - Access or mutate a
static mutvariable - Access a union's fields
That's the entire list. Nothing else changes inside an unsafe block, the borrow checker, move semantics, and type checking are all still fully active. unsafe is closer to "I've manually verified the precondition the compiler can't check here" than "anything goes."
Gotcha: the common misconception is that
unsafe"turns off the borrow checker." It doesn't, an&mutaliasing error inside anunsafeblock still won't compile.unsafeonly grants the five capabilities above; everything you already had to satisfy, you still do.
Raw Pointers: No Compiler-Tracked Validity
A reference (&T) always points to a valid, properly aligned T for as long as it exists, the compiler enforces that. A raw pointer (*const T/*mut T) carries none of those guarantees, it might be null, dangling, misaligned, or pointing at memory that's since been freed. Creating one is safe; dereferencing one requires unsafe, because that's the moment those unchecked guarantees actually matter.
let x = 42;
let ptr: *const i32 = &x;
unsafe {
println!("{}", *ptr); // dereferencing requires unsafe: is `ptr` still valid?
}
The compiler has no way to verify ptr is still valid by the time it's dereferenced, that verification is entirely on you. This is also why raw pointers show up far less in everyday Rust than in C, references already cover the vast majority of cases a pointer would be used for, with the validity guarantee built in.
Why unsafe Exists: Safe Operations the Borrow Checker Can't Prove
The canonical example is splitting a mutable slice into two independent halves. Logically, this is completely safe, the two halves don't overlap, so mutating both at once causes no data race. But the type signature &mut [T] -> (&mut [T], &mut [T]) looks, from the borrow checker's perspective, exactly like producing two overlapping mutable borrows of the same data, which it categorically refuses.
fn split_at_mut_simplified<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let len = slice.len();
let ptr = slice.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
The assert! is what makes the unsafe block sound: it's the runtime check standing in for what the borrow checker can't verify statically, that mid is in bounds, so the two raw-pointer-derived slices genuinely don't overlap. This is exactly the pattern the standard library's own slice::split_at_mut uses internally. The function's public signature is completely safe to call, callers never write unsafe themselves, the unsafe is contained entirely inside an implementation that's been manually verified once.
This is the template for almost all justified unsafe usage: a small, carefully checked unsafe block wrapped in a safe function, so the unsafety doesn't leak out to every caller.
unsafe fn: Documenting an Unverifiable Precondition
Marking a function unsafe fn is a signal, not a capability by itself, it tells callers "calling this correctly depends on a precondition the compiler cannot check; verify it yourself." Document exactly what that precondition is in a # Safety section:
/// # Safety
///
/// `ptr` must be non-null and point to a valid, initialized `T`
/// for the duration of this call.
unsafe fn read_value<T: Copy>(ptr: *const T) -> T {
*ptr
}
Calling an unsafe fn requires wrapping the call in unsafe { ... }, which is your acknowledgment that you've read the precondition and verified it holds for this specific call site. A function that's just risky in some general sense isn't a candidate for unsafe fn, only mark a function unsafe when there's a real precondition the type system cannot express, the way read_value above can't express "this pointer is valid" in its signature.
unsafe trait: Asserting an Invariant the Compiler Can't Check
Concurrency in Practice covered Send and Sync, both are unsafe traits. Implementing one is you personally asserting "I've verified this type upholds the invariant this trait promises," because the compiler has no way to check thread-safety properties on its own.
struct MyHandle(*mut SomeFfiType);
// asserting that MyHandle is actually safe to send across threads,
// something the compiler can't verify about a raw pointer on its own
unsafe impl Send for MyHandle {}
This is rare to need directly, almost everything gets Send/Sync auto-derived correctly from its fields. You'd only write this for a type wrapping something the compiler can't see through, most often a raw pointer from FFI, where you know from the underlying API's contract that it's actually safe to move across threads.
What Goes Wrong: Undefined Behavior, Not Just Panics
A safe Rust bug, an out-of-bounds index, an .unwrap() on None, panics, predictably and loudly. Violating an unsafe invariant doesn't necessarily panic at all, it's undefined behavior: the compiler optimized assuming the invariant held, and once it doesn't, the program's behavior is no longer something you can reason about. Two aliased mutable references through raw pointers might appear to work in a debug build and corrupt memory in a release build, because the optimizer made assumptions that turned out to be false.
This is the core reason unsafe blocks should stay as small as possible and get wrapped in a safe API immediately, like split_at_mut_simplified above: every line inside the block is something you're personally vouching for, and the smaller that surface is, the less there is to get wrong, and the easier it is to actually re-verify when the surrounding code changes.
Quick Reference
| Capability | The invariant you now owe |
|---|---|
| Deref a raw pointer | it's non-null, aligned, and points to a live, initialized value |
Call an unsafe fn | its documented # Safety precondition holds at this call site |
Implement an unsafe trait (Send/Sync) | the type genuinely upholds the trait's promise |
Read/write static mut | no data race — no overlapping access from another thread |
| Access a union field | the field you read is the one that was last written |
Containment rule: keep each unsafe block as small as possible and wrap it in a safe function (like slice::split_at_mut), so callers never write unsafe or reason about the invariant.
Key Takeaways
unsafeunlocks exactly five things: dereferencing raw pointers, callingunsafe fn, implementingunsafe trait, touchingstatic mut, and accessing union fields. Everything else (borrow checking, move semantics, types) still applies.- Raw pointers carry no compiler-verified validity, alignment, or lifetime guarantees, that's exactly what makes dereferencing one require
unsafe. - The standard pattern for justified
unsafeis a small, manually verified block wrapped in a safe public function (likeslice::split_at_mut), so callers never need to writeunsafeor reason about the invariant themselves. - Mark a function
unsafe fnonly when it has a real precondition the type system can't express; document exactly what that precondition is in a# Safetycomment. unsafe trait(likeSend/Sync) is you personally asserting an invariant the compiler can't verify, almost always only needed for types wrapping raw pointers or FFI handles.- Violating an
unsafeinvariant is undefined behavior, not a guaranteed panic, it can appear to work today and break silently under a different optimization level. Keepunsafeblocks minimal for exactly this reason.