The Bridge Between Danger and Safety
You're building a high-performance image processor. You found a SIMD routine written in C that crunches pixels three times faster than your Rust loop. You want to use it, but your library users shouldn't have to wrestle with raw pointers or worry about memory corruption. You need a bridge. You need a safe function that hides the dangerous machinery behind a clean, idiomatic API.
Rust gives you unsafe to talk to the outside world or to implement low-level abstractions. The keyword removes the compiler's guardrails. It lets you dereference raw pointers, call foreign functions, and mutate through shared references. The compiler assumes you know what you're doing. If you're wrong, you get undefined behavior. Undefined behavior means the compiler can assume your code is correct, which leads to optimizations that break your program in subtle, unpredictable ways.
Wrapping unsafe code in a safe API is the standard pattern for containing that risk. You write a function with a safe signature. Inside, you use an unsafe block to perform the operation. The function enforces all the necessary checks before entering the block. Callers use the safe function and never see the unsafe code. They get the performance or functionality without the liability.
Think of unsafe like the exposed wiring inside a toaster. If you touch the wires, you get shocked. The toaster manufacturer wraps those wires in plastic, adds a lever, and gives you a safe appliance. You press the lever. The electricity does its dangerous job inside. You get toast. The wrapper doesn't make the electricity safe. It makes the interface safe by preventing you from touching the wires. In Rust, unsafe blocks are the exposed wires. Your safe function is the plastic casing and the lever.
The Contract You Must Keep
Every unsafe block needs a contract. The contract lists the conditions that must be true before you enter the block. These conditions are called invariants. Common invariants include pointer validity, alignment, ownership, and lifetime. If any invariant is false, the behavior is undefined.
The compiler stops checking rules inside unsafe. It trusts you. You have to do the checking manually. You verify the invariants in the safe code before the unsafe block. You document the invariants in a // SAFETY: comment right above the block. The comment is not optional. It's a proof to future readers and to yourself that you've verified the rules the compiler skipped.
The Rust community follows a strict convention: keep unsafe blocks as small as possible. This is the "minimum unsafe surface" rule. If your unsafe block spans fifty lines, you're likely doing too much at once. Split it. Isolate the dangerous operation. The smaller the block, the easier it is to verify the invariants.
Another convention: run cargo fmt before you commit. Rust code follows a single formatting style. Don't argue about braces or indentation. Argue about logic and invariants. Consistent formatting reduces cognitive load so readers can focus on the safety proof.
Minimal Example
Here's a function that returns the first element of a slice. It uses a raw pointer internally to demonstrate the pattern. The public API is completely safe.
/// Returns the first element of a slice, or None if empty.
/// This demonstrates wrapping raw pointer access in a safe API.
pub fn get_first_item(data: &[i32]) -> Option<i32> {
// Check bounds first to ensure the pointer points to valid data.
if data.is_empty() {
return None;
}
// SAFETY:
// 1. `data` is a valid slice, so `as_ptr()` returns a non-null, aligned pointer.
// 2. The length check guarantees the pointer points to at least one valid element.
// 3. The borrow on `data` keeps the memory alive for the duration of the dereference.
unsafe {
let ptr = data.as_ptr();
Some(*ptr)
}
}
The compiler looks at the function signature. It sees &[i32] and Option<i32>. Both are safe types. The compiler enforces that the function cannot leak memory or violate aliasing rules based on those types. Inside the unsafe block, the compiler relaxes checks. It assumes the invariants hold. If you dereference a null pointer here, the compiler won't stop you. That's why the length check exists outside the block. It enforces the invariant that the pointer is valid.
Callers use this function without unsafe. They get an Option<i32>. They don't need to know about raw pointers. The risk is contained.
Realistic Wrapper
Real-world wrappers often manage state. A struct can hold a raw pointer and provide safe methods to access it. This pattern is common for custom allocators, buffers, and data structures.
use std::ptr;
/// A simple buffer that manages a raw pointer internally.
/// Users interact only through safe methods.
pub struct Buffer {
ptr: *mut u8,
len: usize,
}
impl Buffer {
/// Creates a new buffer with the given capacity.
///
/// # Panics
/// Panics if allocation fails or capacity is zero.
pub fn new(capacity: usize) -> Self {
// Enforce capacity invariant before allocation.
if capacity == 0 {
panic!("Capacity must be greater than zero");
}
let layout = std::alloc::Layout::from_size_align(capacity, 1).unwrap();
// SAFETY:
// 1. `layout` has non-zero size because we checked capacity.
// 2. The global allocator handles alignment and allocation failures.
let ptr = unsafe { std::alloc::alloc(layout) };
if ptr.is_null() {
panic!("Allocation failed");
}
Buffer { ptr, len: capacity }
}
/// Reads a byte at the given index.
pub fn get(&self, index: usize) -> Option<u8> {
// Check bounds before touching the pointer.
if index >= self.len {
return None;
}
// SAFETY:
// 1. `self.ptr` is valid because `new` checked for null.
// 2. `index` is within bounds, so `ptr.add(index)` points to valid memory.
// 3. No other references to the buffer exist that would violate aliasing.
unsafe {
Some(*self.ptr.add(index))
}
}
}
impl Drop for Buffer {
fn drop(&mut self) {
// SAFETY:
// 1. `self.ptr` is valid and allocated by `std::alloc::alloc`.
// 2. `self.len` matches the allocation size.
// 3. This is the only place the memory is freed.
unsafe {
let layout = std::alloc::Layout::from_size_align(self.len, 1).unwrap();
std::alloc::dealloc(self.ptr, layout);
}
}
}
The Buffer struct hides the raw pointer. Users call Buffer::new and buffer.get. They never see *mut u8. The new method enforces the invariant that the pointer is non-null. The get method enforces the invariant that the index is in bounds. The Drop implementation frees the memory exactly once.
The // SAFETY: comments list the invariants. They don't repeat the code. They state the facts that make the unsafe operation valid. If you can't write the comment, you don't have a proof. Don't write the code.
Tests are your safety net. The compiler won't catch logic errors in unsafe blocks. Write tests that push the boundaries. Test with empty buffers. Test with maximum sizes. Test with concurrent access if applicable. Use tools like Miri to detect undefined behavior that tests might miss. Miri runs your code in an interpreter that checks every memory access. It's slower, but it catches the silent bugs that cause production fires.
Pitfalls and Silent Failures
If you try to dereference a raw pointer outside an unsafe block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe block). This is a feature. The compiler is forcing you to acknowledge the risk.
The biggest risk isn't the compiler. It's you. If your safe function misses a bounds check or allows a null pointer to slip through, you've created a time bomb. Callers will use your function in safe contexts, assuming it can't crash or corrupt memory. When it does, the bug report will be confusing because the stack trace points to safe code.
Undefined behavior is the enemy. When you have UB, the compiler assumes the code is correct. It might optimize away a check you think is there. It might reorder memory accesses. It might assume a pointer is never null and delete a null check. The result is code that works in debug mode and corrupts memory in release mode. Wrapping unsafe code forces you to define the boundary where UB cannot leak. If your safe API is correct, UB is contained. If your safe API has a hole, UB leaks out and infects the rest of your program.
Another pitfall is aliasing. If you create a mutable reference and an immutable reference to the same memory at the same time, you violate Rust's aliasing rules. The compiler can't check this inside unsafe. You have to track it manually. If you return a reference from a safe function, the compiler trusts that the reference is valid. If it's not, you've lied to the compiler. Treat the // SAFETY: comment as a proof. If you can't write it, you don't have one.
When you call a function that returns a result you don't need, use let _ = result; to discard it. This signals to readers that you considered the value and chose to drop it. It's a small habit that prevents accidental bugs when the function signature changes later.
When to Reach for Unsafe
Use unsafe for FFI when you call C or another language and have to cross out of Rust's safety guarantees. Use unsafe for performance-critical inner loops where measured profiling shows safe abstractions are the bottleneck, and you isolate the optimization in a small helper. Use unsafe when you're implementing a safe abstraction yourself, like a custom allocator, a linked list, or a data structure that the standard library doesn't provide. Reach for safe alternatives when the compiler can verify the logic; a Vec is almost always better than a raw pointer array. Reach for RefCell or Mutex when you need interior mutability that the borrow checker can't express statically.
Don't fight the compiler here. Reach for safe abstractions first. Only use unsafe when you have a measured reason and a clear path to a safe wrapper.