When & Why Unsafe
Rust's safety guarantees come from the compiler. The borrow checker, the type system, and the ownership rules ensure memory safety and thread safety without a runtime. But some things are impossible to verify at compile time. That is where unsafe comes in — not as an escape hatch from safety, but as a way to tell the compiler "I have verified this myself."
The Five Unsafe Superpowers
Inside an unsafe block, you can do five things that safe Rust forbids:
unsafe {
// 1. Dereference raw pointers
let ptr: *const i32 = &42;
let value = *ptr;
// 2. Call unsafe functions
some_unsafe_function();
// 3. Access mutable static variables
COUNTER += 1;
// 4. Implement unsafe traits
// (done at the impl level, not inside a block)
// 5. Access fields of unions
let u = MyUnion { i: 42 };
let value = u.f;
}
That is it. Five things. Everything else — borrowing, lifetimes, type checking, bounds checking — still works normally inside unsafe. Writing unsafe does not turn off the borrow checker.
Why Unsafe Exists
Some valid programs cannot be expressed in safe Rust. The compiler is conservative — it rejects anything it cannot prove safe, even if the programmer knows it is correct.
Raw Pointers for Data Structures
Doubly-linked lists, graphs with cycles, and self-referential structures require raw pointers because Rust's ownership model cannot express shared mutable references:
struct Node {
value: i32,
next: *mut Node,
prev: *mut Node,
}
Safe Rust has no way to express "two nodes point to each other." Raw pointers can.
FFI with C Libraries
Calling C code requires unsafe because the compiler cannot verify C's memory behavior:
extern "C" {
fn strlen(s: *const std::ffi::c_char) -> usize;
}
fn safe_strlen(s: &std::ffi::CStr) -> usize {
unsafe { strlen(s.as_ptr()) }
}
The C function makes no guarantees about null pointers, buffer boundaries, or thread safety. The Rust wrapper must ensure correctness.
Performance-Critical Code
Occasionally, bounds checking or other safety mechanisms are a measurable bottleneck:
fn sum_unchecked(data: &[i32]) -> i32 {
let mut total = 0;
for i in 0..data.len() {
// Safe version: data[i] — bounds checked every access
// Unsafe version: skip the check when we know i < data.len()
unsafe {
total += *data.get_unchecked(i);
}
}
total
}
In practice, LLVM often eliminates bounds checks in simple loops. Use get_unchecked only when benchmarks prove the bounds check is a bottleneck.
The Safety Contract
unsafe does not mean "anything goes." It means "the programmer is responsible for upholding Rust's invariants." Those invariants include:
- No dangling pointers. Every pointer you dereference must point to valid, allocated memory.
- No data races. Mutable access must be exclusive. No two threads can write to the same memory without synchronization.
- No invalid values. A
boolmust be 0 or 1. A reference must be non-null and aligned. An enum must hold a valid discriminant. - No aliasing violations. You cannot have a
&mut Tand a&Tto the same data simultaneously.
Violating any of these is undefined behavior, even inside unsafe. The compiler is free to assume they hold and will optimize accordingly. UB can cause your program to do anything — crash, corrupt data, or appear to work until it does not.
// This is undefined behavior — do not do this
unsafe {
let mut x = 42;
let r1 = &x as *const i32;
let r2 = &mut x as *mut i32;
*r2 = 100;
println!("{}", *r1); // UB: r1 and r2 alias
}
Minimizing Unsafe: Safe Abstractions
The best unsafe code is code nobody needs to see. Wrap it in a safe interface:
pub struct SafeBuffer {
data: Vec<u8>,
position: usize,
}
impl SafeBuffer {
pub fn new(size: usize) -> Self {
SafeBuffer {
data: vec![0; size],
position: 0,
}
}
pub fn read_byte(&mut self) -> Option<u8> {
if self.position >= self.data.len() {
return None;
}
// Safe because we checked bounds above
let byte = unsafe { *self.data.get_unchecked(self.position) };
self.position += 1;
Some(byte)
}
}
The caller sees only a safe API. The unsafe block is small, isolated, and justified by the bounds check immediately above it. If the invariant (position < length) is maintained by all methods, the unsafe code is sound.
This pattern — safe API wrapping minimal unsafe — is how the standard library works. Vec, String, HashMap, Arc, and Mutex all contain unsafe code internally but expose safe interfaces.
The Unsafe Audit
In production codebases, unsafe blocks should be:
- Minimal. Do as little as possible inside the unsafe block.
- Documented. Explain why it is safe with a
// SAFETY:comment. - Audited. Reviewed by someone who understands the invariants.
pub fn get_unchecked(&self, index: usize) -> &T {
debug_assert!(index < self.len, "index out of bounds");
// SAFETY: caller must ensure index < self.len.
// debug_assert catches violations in debug builds.
unsafe { &*self.ptr.add(index) }
}
The // SAFETY: comment is a convention in the Rust community. It forces the author to articulate why the unsafe operation is correct.
What Unsafe Is Not
Unsafe is not "turn off the borrow checker." The borrow checker runs normally inside unsafe blocks. You cannot create aliased mutable references with &mut — you need raw pointers for that, and then you are responsible for ensuring no aliasing.
Unsafe is not C. In C, every line of code is implicitly unsafe. In Rust, you opt into specific operations and the rest of the code retains all safety guarantees.
Unsafe is not inherently wrong. Every systems programming language needs a way to do low-level operations. What matters is minimizing the surface area and wrapping it in safe abstractions.
Common Pitfalls
- Using unsafe to work around the borrow checker. If the borrow checker rejects your code, the solution is almost always a different design, not unsafe. Reach for
RefCell,Rc,Arc, or restructure your data. - Large unsafe blocks. The more code inside unsafe, the harder it is to verify correctness. Keep blocks as small as possible.
- Missing SAFETY comments. Every unsafe block should explain the invariant that makes it correct. If you cannot write the comment, you cannot be sure the code is safe.
- Assuming unsafe is faster. Unsafe does not make code faster by default. It only removes specific checks. If those checks are not your bottleneck, unsafe adds risk for no benefit.
- Not testing unsafe code. Use Miri (
cargo miri test) to detect undefined behavior. It catches aliasing violations, use-after-free, and uninitialized memory access.
Key Takeaways
unsafegrants five specific superpowers. It does not disable the borrow checker or type system.- Unsafe exists for raw pointers, FFI, performance-critical sections, mutable statics, and unions.
- The safety contract requires you to uphold Rust's invariants: no dangling pointers, no data races, no invalid values.
- Minimize unsafe by wrapping it in safe abstractions. The standard library itself is built this way.
- Document every unsafe block with a
// SAFETY:comment explaining why it is correct. - Use Miri to test unsafe code for undefined behavior.