When references refuse to cooperate
You are building a custom memory allocator. You need to track free blocks, link them together, and occasionally mutate a node while another thread reads a different part of the same structure. Or you are calling a legacy C library that expects a pointer to a buffer, and the library will write back results asynchronously. Or you are implementing a graph where nodes point to each other in cycles, and Rust's ownership model refuses to compile because it cannot determine which node owns which.
References hit a wall here. They enforce strict lifetimes, forbid aliasing with mutable access, and demand a clear owner. Raw pointers step in when those rules become more obstacle than safety net.
The napkin address analogy
A Rust reference is like checking out a book from a library. The librarian tracks who has it, when they will return it, and whether anyone else is reading it. You cannot tear pages out. You cannot give the book to someone else without checking it out again. The system guarantees the book exists while you hold it.
A raw pointer is like writing a house address on a napkin. You can make as many copies of the napkin as you want. You can pass them around. You can scribble over them. The librarian does not track them. When you walk to that address, you might find a house, an empty lot, or a building that burned down last week. The napkin does not care. You decide whether it is safe to knock on the door.
Rust hands you the napkin only when you explicitly ask for it. The language does not trust you with it by default. You must wrap every use in an unsafe block and prove to yourself that the address is valid.
Creating and using raw pointers
Raw pointers come in two flavors. *const T points to data you promise not to modify through that pointer. *mut T points to data you intend to mutate. The names describe your intent, not the compiler's enforcement. The compiler will not stop you from casting a *const to a *mut or vice versa. It trusts your judgment.
fn main() {
let x = 5;
// Cast a regular reference to a raw pointer.
// This does not copy the data. It just extracts the memory address.
let r = &x as *const i32;
let m = &mut x as *mut i32;
// Dereferencing requires unsafe because the compiler cannot verify validity.
unsafe {
println!("Value: {}", *r);
*m = 10;
}
}
The as cast is the bridge between safe Rust and raw memory. It tells the compiler to stop tracking lifetimes and aliasing rules for that specific value. The pointer itself is just a number. Dereferencing it is where the danger lives.
What the compiler actually does
When you create a raw pointer, the borrow checker steps back. It does not record the pointer in its lifetime table. It does not check whether the underlying data will outlive the pointer. It does not verify that you are not creating multiple mutable aliases. It simply compiles the cast and moves on.
At runtime, the pointer is an integer representing a memory address. Reading from it fetches bytes from that address. Writing to it overwrites them. If the address is null, the program crashes. If the address points to freed memory, you read garbage or corrupt other data. If you mutate through one pointer while another thread reads through a second pointer, you trigger a data race. The operating system or the CPU will not save you. Undefined behavior takes over.
Rust forces you to acknowledge this responsibility. Every dereference or mutation must live inside an unsafe block. The compiler does not check the block's contents for safety. It only checks that you explicitly opted in. This is a social contract, not a technical guarantee.
A realistic wrapper pattern
Raw pointers rarely live naked in production code. They sit inside safe abstractions. The pattern is consistent: allocate or receive the raw pointer, wrap it in a struct, and expose safe methods that enforce invariants. The unsafe blocks stay small and isolated.
/// A simple buffer that owns its memory and exposes raw pointers for FFI.
struct RawBuffer {
ptr: *mut u8,
len: usize,
}
impl RawBuffer {
/// Allocates a zeroed buffer of the given length.
fn new(len: usize) -> Self {
// Allocate memory using std::alloc. We assume the caller handles allocation errors.
let layout = std::alloc::Layout::from_size_align(len, 1).unwrap();
let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
// SAFETY:
// 1. The layout has a non-zero size if len > 0.
// 2. alloc_zeroed returns a valid, aligned pointer or aborts on failure.
// 3. We immediately wrap the pointer to prevent leaks.
if ptr.is_null() {
panic!("Allocation failed");
}
Self { ptr, len }
}
/// Returns a raw pointer to the buffer's start.
fn as_ptr(&self) -> *const u8 {
self.ptr as *const u8
}
/// Writes a value at the given index.
fn set(&mut self, index: usize, value: u8) {
// Bounds check happens in safe code before touching the pointer.
if index >= self.len {
panic!("Index out of bounds");
}
unsafe {
// SAFETY:
// 1. index is strictly less than self.len.
// 2. self.ptr was allocated with exactly self.len bytes.
// 3. We are the only mutable reference to this RawBuffer.
*self.ptr.add(index) = value;
}
}
}
impl Drop for RawBuffer {
fn drop(&mut self) {
// Deallocate the memory when the buffer is no longer needed.
let layout = std::alloc::Layout::from_size_align(self.len, 1).unwrap();
unsafe {
// SAFETY:
// 1. ptr was allocated with the same layout.
// 2. No other pointers to this allocation exist.
// 3. The buffer is being destroyed, so no further access will occur.
std::alloc::dealloc(self.ptr, layout);
}
}
}
The safe API handles bounds checking, lifetime management, and cleanup. The unsafe blocks only touch the raw pointer when the invariants are already proven. This is the minimum unsafe surface rule. Keep the dangerous code isolated. Document the proof. Let the rest of your codebase stay safe.
Convention aside: the community prefers std::ptr::NonNull<T> over *mut T when null is logically impossible. NonNull carries a compile-time guarantee that the pointer is never null, and it plays nicely with std::mem::MaybeUninit and advanced optimizations. Use it when you control allocation and can prove the address is valid.
The landmines and how to avoid them
Raw pointers break three core Rust guarantees. Each one maps to a specific class of bugs.
Dangling pointers happen when the data is dropped but the pointer remains. The borrow checker normally prevents this by tying pointer lifetimes to data lifetimes. With raw pointers, you must track scope manually. If you cast a reference to a raw pointer, store it, and then the original variable goes out of scope, the pointer points to freed stack memory. Accessing it is undefined behavior.
Null dereferences happen when you assume a pointer points to valid memory. C libraries frequently return null to signal errors. Rust's Option<T> handles this safely. Raw pointers do not. You must check ptr.is_null() before dereferencing. The compiler will not remind you.
Aliasing violations happen when you mutate data through a *mut T while another pointer or reference reads or writes the same memory. Rust's aliasing rules state that you may have multiple immutable references, or exactly one mutable reference. Raw pointers bypass this rule. If you break it, the compiler may optimize your code in ways that silently corrupt data. The optimizer assumes the rules hold. When they do not, the generated machine code can reorder or eliminate memory accesses.
If you try to dereference a raw pointer outside an unsafe block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This is a compile-time guard. It does not check whether the pointer is valid. It only checks whether you acknowledged the risk.
Treat the unsafe block as a boundary, not a permission slip. Every dereference inside it must be backed by a documented invariant. If you cannot write down why it is safe, it is not safe.
Choosing the right pointer type
Use &T or &mut T when lifetimes are clear and the borrow checker can verify safety. References are zero-cost, automatically tracked, and impossible to misuse. Reach for them first.
Use Box<T> when you need heap allocation with a single owner. The box manages the memory for you and guarantees the pointer is always valid. It integrates seamlessly with Rust's trait system and drop semantics.
Use Rc<T> or Arc<T> when multiple parts of your program need to read the same data. Reference counting keeps the data alive as long as any clone exists. The compiler enforces thread safety for Arc and single-thread safety for Rc.
Use *const T when you are crossing an FFI boundary and the external code expects a read-only pointer. Use it when you need to break a reference cycle in a graph or tree. Use it when you are implementing a safe abstraction and need to hold a pointer without claiming ownership.
Use *mut T when you are implementing a safe abstraction that requires interior mutability or manual memory management. Use it when you are building a custom allocator, a low-level cache, or a data structure that outlives standard Rust ownership patterns. Use it when you must mutate memory through a pointer that cannot be tracked by the borrow checker.
Counter-intuitive but true: the more raw pointers you use, the harder the rest of your code becomes to reason about. Isolate them. Wrap them. Prove them. Let safe Rust do the heavy lifting.