Rust Unsafe: When and How to Use Unsafe Code
Rust's memory safety guarantees are its defining feature, but they come with a critical escape hatch: the `unsafe` keyword. This isn't a design flaw—it's a pragmatic acknowledgment that some...
Key Insights
- Unsafe Rust doesn’t disable the borrow checker—it grants five specific superpowers that let you perform operations the compiler can’t verify, while you maintain responsibility for upholding safety invariants.
- Most legitimate uses of unsafe fall into three categories: FFI bindings, performance-critical optimizations with proven bottlenecks, and building safe abstractions over inherently unsafe primitives.
- The golden rule of unsafe code is encapsulation: keep unsafe blocks minimal, document all safety invariants explicitly, and expose only safe APIs to callers.
Understanding Unsafe in Rust’s Safety Model
Rust’s memory safety guarantees are its defining feature, but they come with a critical escape hatch: the unsafe keyword. This isn’t a design flaw—it’s a pragmatic acknowledgment that some operations are inherently impossible for the compiler to verify, yet remain necessary for systems programming.
The unsafe keyword doesn’t disable Rust’s safety checks. The borrow checker still runs, lifetimes still matter, and type safety remains enforced. Instead, unsafe is a contract: you’re telling the compiler “I’ve verified these operations are sound, trust me.” The compiler, in turn, holds you responsible for upholding invariants it can’t check.
Here’s the fundamental difference:
// Safe: compiler verifies this is sound
fn safe_access(slice: &[i32], index: usize) -> Option<i32> {
slice.get(index).copied()
}
// Unsafe: you guarantee the index is valid
unsafe fn unsafe_access(slice: &[i32], index: usize) -> i32 {
*slice.get_unchecked(index)
}
// Raw pointer dereferencing requires unsafe
fn raw_pointer_example() {
let x = 42;
let ptr = &x as *const i32;
// This is safe Rust - creating raw pointers is fine
let ptr2 = ptr;
// This requires unsafe - dereferencing might be invalid
unsafe {
println!("Value: {}", *ptr);
}
}
The Five Unsafe Superpowers
Unsafe code grants exactly five capabilities that safe Rust prohibits:
// 1. Dereference raw pointers
unsafe fn superpower_one() {
let mut x = 42;
let ptr = &mut x as *mut i32;
*ptr = 100; // Requires unsafe
}
// 2. Call unsafe functions
unsafe fn dangerous_operation() {
// Implementation
}
fn superpower_two() {
unsafe {
dangerous_operation(); // Requires unsafe
}
}
// 3. Access or modify mutable static variables
static mut COUNTER: i32 = 0;
fn superpower_three() {
unsafe {
COUNTER += 1; // Requires unsafe
}
}
// 4. Implement unsafe traits
unsafe trait UnsafeTrait {
fn method(&self);
}
// Implementation requires unsafe keyword
unsafe impl UnsafeTrait for i32 {
fn method(&self) {}
}
// 5. Access union fields
union MyUnion {
i: i32,
f: f32,
}
fn superpower_five() {
let u = MyUnion { i: 42 };
unsafe {
println!("{}", u.f); // Requires unsafe
}
}
Understanding these five operations is crucial. If you’re writing unsafe for any other reason, you’re likely misunderstanding Rust’s safety model.
Legitimate Use Cases for Unsafe Code
FFI Bindings
The most common legitimate use of unsafe is interfacing with C libraries. Foreign functions can’t provide Rust’s safety guarantees, so calling them requires unsafe:
// C library binding
extern "C" {
fn compress(
dest: *mut u8,
dest_len: *mut usize,
source: *const u8,
source_len: usize,
) -> i32;
}
// Safe wrapper that upholds Rust invariants
pub fn compress_data(source: &[u8]) -> Result<Vec<u8>, CompressionError> {
let mut dest = vec![0u8; source.len() * 2];
let mut dest_len = dest.len();
let result = unsafe {
compress(
dest.as_mut_ptr(),
&mut dest_len,
source.as_ptr(),
source.len(),
)
};
if result == 0 {
dest.truncate(dest_len);
Ok(dest)
} else {
Err(CompressionError::Failed(result))
}
}
#[derive(Debug)]
pub enum CompressionError {
Failed(i32),
}
Notice how the unsafe block is minimal, and the public API is completely safe. Callers never touch raw pointers or unsafe code.
Performance Optimizations
Sometimes you need to bypass bounds checks in performance-critical code. But measure first—premature optimization is still the root of all evil:
pub fn sum_safe(data: &[i32]) -> i32 {
data.iter().sum()
}
// Only use after profiling proves the bounds check is a bottleneck
pub fn sum_unchecked(data: &[i32]) -> i32 {
let mut sum = 0;
for i in 0..data.len() {
// SAFETY: i is always < data.len() by loop bounds
unsafe {
sum += data.get_unchecked(i);
}
}
sum
}
Building Safe Abstractions
Many of Rust’s standard library types use unsafe internally to provide safe APIs. Here’s a simplified vector-like structure:
pub struct SimpleVec<T> {
ptr: *mut T,
len: usize,
capacity: usize,
}
impl<T> SimpleVec<T> {
pub fn new() -> Self {
SimpleVec {
ptr: std::ptr::NonNull::dangling().as_ptr(),
len: 0,
capacity: 0,
}
}
pub fn push(&mut self, value: T) {
if self.len == self.capacity {
self.grow();
}
unsafe {
// SAFETY: We just ensured capacity > len
// ptr is valid for capacity elements
std::ptr::write(self.ptr.add(self.len), value);
}
self.len += 1;
}
pub fn get(&self, index: usize) -> Option<&T> {
if index < self.len {
unsafe {
// SAFETY: index < len, and ptr is valid for len elements
Some(&*self.ptr.add(index))
}
} else {
None
}
}
fn grow(&mut self) {
let new_capacity = if self.capacity == 0 { 1 } else { self.capacity * 2 };
let layout = std::alloc::Layout::array::<T>(new_capacity).unwrap();
let new_ptr = unsafe {
std::alloc::alloc(layout) as *mut T
};
if !new_ptr.is_null() {
unsafe {
std::ptr::copy_nonoverlapping(self.ptr, new_ptr, self.len);
if self.capacity > 0 {
let old_layout = std::alloc::Layout::array::<T>(self.capacity).unwrap();
std::alloc::dealloc(self.ptr as *mut u8, old_layout);
}
}
self.ptr = new_ptr;
self.capacity = new_capacity;
}
}
}
Common Pitfalls and How to Avoid Them
The most dangerous mistake is violating aliasing rules, leading to undefined behavior:
// WRONG: Creates aliasing mutable references
fn broken_split(slice: &mut [i32]) -> (&mut [i32], &mut [i32]) {
let mid = slice.len() / 2;
// This doesn't compile - good!
// (&mut slice[..mid], &mut slice[mid..])
// DON'T DO THIS - undefined behavior!
unsafe {
let ptr = slice.as_mut_ptr();
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr, slice.len() - mid), // Wrong offset!
)
}
}
// CORRECT: Proper offset calculation
fn correct_split(slice: &mut [i32]) -> (&mut [i32], &mut [i32]) {
let mid = slice.len() / 2;
unsafe {
let ptr = slice.as_mut_ptr();
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), slice.len() - mid),
)
}
}
Always document your safety invariants with SAFETY comments explaining why the unsafe operation is sound.
Tools and Testing for Unsafe Code
Miri is your best friend for detecting undefined behavior. It’s an interpreter that can catch issues like use-after-free and invalid pointer arithmetic:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_vec() {
let mut vec = SimpleVec::new();
vec.push(1);
vec.push(2);
vec.push(3);
assert_eq!(vec.get(0), Some(&1));
assert_eq!(vec.get(1), Some(&2));
assert_eq!(vec.get(2), Some(&3));
assert_eq!(vec.get(3), None);
}
}
Run with: cargo +nightly miri test
Additionally, use sanitizers in CI:
RUSTFLAGS="-Z sanitizer=address" cargo +nightly test
RUSTFLAGS="-Z sanitizer=memory" cargo +nightly test
Fuzzing is particularly effective for unsafe code. Use cargo-fuzz to generate random inputs that might trigger edge cases your tests miss.
Balancing Safety and Control
Most Rust code should never need unsafe. Before reaching for it, ask yourself:
- Have I profiled and confirmed this is actually a bottleneck?
- Can I redesign my API to avoid unsafe entirely?
- Am I certain I understand all the safety invariants?
When you do use unsafe, follow these principles:
- Minimize scope: Keep unsafe blocks as small as possible
- Document invariants: Every unsafe block needs a
SAFETYcomment - Encapsulate: Expose safe APIs, hide unsafe implementations
- Test thoroughly: Use Miri, sanitizers, and fuzzing
- Audit regularly: Unsafe code deserves extra scrutiny in code reviews
Unsafe Rust is a powerful tool, but it’s also a responsibility. The compiler trusts you to maintain invariants it can’t verify. Respect that trust by writing defensive, well-documented unsafe code, and only when absolutely necessary. The vast majority of Rust applications—even performance-critical ones—can be written entirely in safe Rust.