The vault and the teller window
You have a raw pointer. It came from a C library, or maybe you're implementing a custom allocator, or you're parsing a binary format where the data lives in a memory-mapped file. You know the pointer is valid right now. You know the memory is live. You know the alignment is correct. Rust doesn't know any of that. To the compiler, *mut u8 is just an address with no guarantees. If you pass that pointer around, anyone can dereference it and crash the process. Worse, if you make a mistake, the compiler might optimize your code into nonsense because it assumes pointers are never null.
You need a way to use that power without handing everyone a loaded gun. You write a safe abstraction. The unsafe code stays locked in a small box, and the rest of your application talks to a safe API that guarantees invariants. The abstraction checks the inputs, performs the dangerous operation in a tightly controlled unsafe block, and returns a safe result. The caller gets a value or an error. They never touch the pointer. They can't cause a segfault. They can't violate alignment. The risk is isolated to a single, audited location.
Invariants and the minimum unsafe surface
The core idea is isolation. You write a function or a struct that takes safe inputs, validates them, does the raw operation, and returns a safe output. The unsafe block is the vault. The public function is the teller window. Customers never see the cash or the guns. They hand in a slip and get a receipt. If the teller messes up, the vault is still secure. If the vault is secure, the teller can't break the bank.
This relies on invariants. An invariant is a condition that must always be true for your data structure. For a raw pointer wrapper, invariants might include: the pointer is non-null, the pointer is aligned for the type, the memory is valid and live, and the length does not overflow. Your safe API must maintain these invariants. If the invariants hold, the unsafe block is safe. If they don't, you have undefined behavior.
The community follows the "minimum unsafe surface" rule. Keep the unsafe block as small as possible. Every line of code outside that block is guaranteed safe by the compiler. If you put too much logic inside unsafe, you lose the compiler's help. You also make it harder to audit the code. The goal is to shrink the area where you have to think about safety until it's just the raw operation itself.
Convention aside: the community treats // SAFETY: comments as proofs. If you write an unsafe block, you must write a comment explaining why it's safe. List the invariants you checked. If you can't write the comment, you don't have a safe abstraction. You have a guess.
A minimal dereference wrapper
Here is the simplest pattern. You have a raw pointer to an integer. You want to read it, but you want to handle the case where the pointer is null. You write a function that checks for null before entering the unsafe block.
/// Reads an i32 from a raw pointer.
/// Returns None if the pointer is null.
pub fn safe_read(ptr: *const i32) -> Option<i32> {
// Check the first invariant: the pointer must not be null.
// If it is null, we return early. No unsafe code runs.
if ptr.is_null() {
return None;
}
// SAFETY:
// 1. ptr is non-null (verified by the check above).
// 2. ptr is aligned for i32 (caller guarantee).
// 3. The memory at ptr is valid and live (caller guarantee).
unsafe {
// Dereference the pointer.
// This is safe because the invariants hold.
Some(*ptr)
}
}
The function starts with a check. ptr.is_null() returns a boolean. If true, we return None. The function exits. The unsafe block is never reached. This is important. The unsafe code is guarded. If the pointer is not null, we proceed. We enter the unsafe block. Inside, we dereference *ptr. The compiler allows this because we are inside unsafe. The compiler does not check the pointer. We checked it. The result is wrapped in Some. We return Option<i32>. The caller receives the value. The caller cannot cause a segfault. The caller cannot violate alignment. The abstraction has contained the risk.
If you try to dereference *ptr outside the unsafe block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe block). This error is a feature. It forces you to isolate the danger. It reminds you that raw pointers are not safe to use directly.
Trust the borrow checker. It usually has a point. When it screams E0133, it's telling you to wrap the operation.
A realistic buffer wrapper
Real code rarely just dereferences a pointer once. You often need to access a buffer multiple times, check bounds, and manage the lifetime of the data. A struct wrapper is the right tool. It encapsulates the pointer and enforces invariants on every access.
/// A safe wrapper around a raw pointer to a byte buffer.
/// Ensures bounds checking and null safety.
pub struct SafeSlice {
ptr: *const u8,
len: usize,
}
impl SafeSlice {
/// Creates a SafeSlice from a raw pointer and length.
/// Returns None if the pointer is null or length is zero.
pub fn new(ptr: *const u8, len: usize) -> Option<Self> {
// Validate invariants at construction time.
// This prevents creating an invalid object.
if ptr.is_null() || len == 0 {
return None;
}
Some(Self { ptr, len })
}
/// Gets a byte at the specified index.
/// Returns None if the index is out of bounds.
pub fn get(&self, index: usize) -> Option<u8> {
// Check bounds before accessing memory.
// This maintains the safety invariant.
if index >= self.len {
return None;
}
// SAFETY:
// 1. self.ptr is non-null (invariant of SafeSlice::new).
// 2. index is within bounds (checked above).
// 3. u8 has alignment 1, so alignment is always satisfied.
// 4. The memory is valid for the duration of the borrow.
unsafe {
// Calculate the address and dereference.
// ptr.add(index) performs pointer arithmetic safely.
Some(*self.ptr.add(index))
}
}
}
The struct holds the pointer and the length. The new function validates the invariants. If the pointer is null or the length is zero, it returns None. You can't create a SafeSlice with invalid data. The get method checks bounds. If the index is out of range, it returns None. No unsafe code runs. If the index is valid, we enter the unsafe block. We use ptr.add(index) to calculate the address. This is safer than raw arithmetic because it handles overflow correctly. We dereference the result. The // SAFETY comment lists the invariants. We checked non-null in new. We checked bounds in get. Alignment is trivial for u8. The memory is valid because the caller guarantees it. The abstraction is complete.
Encapsulate the pointer. Expose the behavior. Never leak the raw address.
Pitfalls and the optimizer trap
The biggest pitfall is incomplete invariants. You check for null, but you forget alignment. You check alignment, but you forget that the memory might be freed. You check everything, but you return a reference that outlives the data. Undefined behavior (UB) is not just a crash. It breaks the compiler's assumptions.
The compiler assumes your code has no UB. If you have UB, the compiler might optimize your code in ways that make the bug worse. For example, if you dereference a raw pointer without checking for null, the compiler assumes the pointer is never null. It might delete your null check because it thinks the check is redundant. Your guard disappears. The crash happens anyway, but now you can't even debug it because the check is gone. This is the optimizer trap.
Alignment is another silent killer. Dereferencing a misaligned pointer is UB. Even if the memory is valid, if the address isn't a multiple of the type's alignment, you have UB. Use is_aligned_to to check. For i32, the alignment is usually 4. If the pointer address is 0x1001, dereferencing it as i32 is UB.
Lifetimes are the third trap. Raw pointers don't have lifetimes. If you return a reference from an unsafe block, you must ensure the reference doesn't outlive the data. If the data is freed, you have a dangling reference. The abstraction must manage this. If the wrapper owns the memory, implement Drop. If it borrows the memory, the wrapper must carry a lifetime parameter, or you must document that the data must live longer than the wrapper.
Convention aside: keep unsafe blocks small. If your unsafe block has more than five lines, ask yourself if you can split it. Can you extract a helper function? Can you use std::ptr methods like read or write to reduce the logic? The smaller the block, the easier it is to verify the safety proof.
Undefined behavior breaks the compiler's assumptions. Fix the invariants, or the optimizer will break your code.
When to use safe abstractions
Use a safe abstraction when you need to expose raw pointer operations to other parts of your codebase or to users of your library. The abstraction validates inputs once and provides a safe API that prevents misuse. It centralizes the safety logic and makes the rest of the code easier to reason about.
Use an inline unsafe block when the operation is a one-off, the invariants are obvious, and wrapping it in a function adds no value. Keep the block small and comment the safety proof. This is common in performance-critical inner loops where the overhead of a function call matters, but only after profiling confirms the need.
Use std::ptr::NonNull when you want to encode the non-null invariant in the type system. NonNull<T> guarantees the pointer is never null, which helps the compiler optimize and reduces the need for runtime checks. It's a building block for safe abstractions, not a replacement for them.
Reach for std::slice::from_raw_parts when you need to create a slice from a raw pointer and length. This function is safe to call inside an unsafe block once you have verified the pointer, length, and alignment. It gives you a &[T] that you can use with all the safe slice methods.
Pick MaybeUninit when you are dealing with uninitialized memory. Raw pointers often point to memory that hasn't been written yet. MaybeUninit lets you handle that state safely before converting to a real value. It prevents accidental drops of uninitialized data, which is UB.
Isolate the risk. Validate the inputs. Keep the safe world safe.