When safe Rust hits a wall
You're building a game engine. You've got a chunk of memory allocated by a C library. You need to read a texture from it. You try to wrap it in a Vec, but the data isn't owned by Rust. You try to use a reference, but the lifetime doesn't match the C library's lifecycle. The borrow checker blocks you. You know the memory is valid. You know the C library won't free it while you're reading. You need to bypass the checks. You write unsafe.
This is the moment Rust hands you the reins. You can do anything. You can dereference raw pointers. You can call C functions. You can mutate static variables. You can implement traits that aren't implemented for your type. The compiler stops checking. The burden of proof moves to you. This isn't a feature to use lightly. It's a tool for when safe Rust can't express what you need.
It's not a magic wand. It's a contract.
The inspector steps aside
Rust's safety model relies on invariants. The borrow checker enforces these invariants at compile time. unsafe tells the compiler to skip the enforcement. It doesn't change the rules of the universe. The hardware still has rules. The ABI still has rules. If you break those rules, you get undefined behavior.
Undefined behavior means the program can do anything. It can crash. It can silently corrupt data. It can appear to work perfectly on your machine and fail on the customer's machine. The compiler might even optimize your code into a loop that never ends because it assumed a pointer was valid and removed a null check.
The community calls this the "minimum unsafe surface" rule. Keep the unsafe block as small as possible. Wrap the dangerous operation, not the whole function. If you have a bug in your unsafe code, you want it to be easy to find. A small unsafe block is easier to audit than a whole module.
Convention aside: Mark a function unsafe if the caller must uphold invariants. Keep the function safe if it can verify everything internally. This signals to users whether they need to write a proof or if the library handles it.
Wrap the dangerous operation, not the whole function.
A minimal example
Here is the simplest case. You have a raw pointer. You need to read the value it points to.
/// Dereferences a raw pointer to read an integer.
/// This function is unsafe because it trusts the caller to provide a valid pointer.
unsafe fn read_value(ptr: *const i32) -> i32 {
// SAFETY: The caller guarantees `ptr` is valid and aligned.
// We dereference the pointer to get the value.
*ptr
}
fn main() {
let x = 42;
// Create a raw pointer from a safe reference.
let ptr = &x as *const i32;
// The unsafe block is small. Only the dereference happens here.
let value = unsafe { read_value(ptr) };
println!("Value: {}", value);
}
The // SAFETY: comment lists the invariants. In this case, the pointer must be valid and aligned. The comment explains why those invariants hold. This isn't just documentation. It's a proof. If you can't write the proof, you don't have the right to use unsafe.
Convention aside: Use Rc::clone(&data) instead of data.clone() when cloning reference counts. The explicit form makes it clear you're cloning the reference, not the data. The same clarity applies to unsafe code. Be explicit about what you're doing.
Treat the SAFETY comment as a proof. If you can't write it, you don't have one.
What happens under the hood
When the compiler sees unsafe, it assumes you have verified the preconditions. It generates machine code based on that assumption. For example, if you dereference a pointer, the compiler assumes the pointer is aligned and valid. It won't generate a check for null. It won't generate a check for bounds. The generated code is faster because it skips the checks.
At runtime, the CPU executes the instructions. If you access invalid memory, the OS might kill your process with a segmentation fault. Or the memory might be mapped to something else, and you read garbage. Or you might overwrite a critical system variable, and the program crashes hours later.
The danger isn't just immediate crashes. It's silent corruption that's impossible to debug. The compiler gave you the keys to the car. It didn't remove the brakes. You just turned off the seatbelt warning.
Realistic patterns
Most unsafe code falls into a few categories. Foreign function interfaces are the most common. You call C code, and C code doesn't know about Rust's borrow checker.
// Link to a hypothetical C library.
extern "C" {
fn c_process_data(buffer: *const u8, len: usize) -> i32;
}
/// Processes a buffer using a C function.
/// This function is safe because it verifies the pointer validity before calling unsafe code.
fn process_data(data: &[u8]) -> i32 {
// SAFETY: `data.as_ptr()` returns a valid pointer for the lifetime of the slice.
// The slice guarantees the memory is initialized and aligned.
// We pass the length so the C function knows the bounds.
unsafe { c_process_data(data.as_ptr(), data.len()) }
}
The function process_data is safe. It takes a slice, which guarantees validity. It extracts a raw pointer and passes it to C. The unsafe block is tiny. The SAFETY comment explains that the slice guarantees the pointer is valid.
Convention aside: Use data.as_ptr() instead of &data[0] as *const u8. The as_ptr() method is explicit and handles empty slices correctly. It returns a dangling pointer that's valid for zero-length access, which avoids out-of-bounds reads on empty data.
Another pattern is building a safe abstraction. You write the unsafe code once, and users get a safe API.
struct MyVec {
ptr: *mut i32,
len: usize,
cap: usize,
}
impl MyVec {
/// Pushes a value onto the vector.
/// This function is safe because it manages the memory internally.
fn push(&mut self, value: i32) {
if self.len == self.cap {
self.grow();
}
// SAFETY: `self.ptr` is valid and has enough capacity.
// `self.len` is within bounds.
// We write to the next available slot.
unsafe {
*self.ptr.add(self.len) = value;
}
self.len += 1;
}
fn grow(&mut self) {
// Implementation omitted.
// Would allocate new memory, copy data, free old memory.
}
}
The push method is safe. It checks capacity before writing. The unsafe block only runs when the check passes. The SAFETY comment lists the invariants: the pointer is valid, and the index is within bounds.
Convention aside: Use ptr.add(offset) for pointer arithmetic when the offset is a usize and you're staying within allocated bounds. It's clearer than ptr.offset() and communicates intent better.
Treat the SAFETY comment as a proof. If you can't write it, you don't have one.
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 or function). This error is your friend. It stops you from accidentally using raw pointers in safe code.
Aliasing is the silent killer. Rust's reference model says you can have many immutable references or one mutable reference, but not both at the same time. Raw pointers bypass this. You can create two mutable raw pointers to the same memory. You can dereference them both. The compiler won't stop you.
But the optimizer uses the aliasing rules to reorder instructions. If you violate the rules, the optimizer might reorder your memory accesses in a way that breaks your logic. Your code might work in debug mode and fail in release mode. This is why unsafe requires a mental model of the hardware and the compiler, not just the language syntax.
Another pitfall is dangling pointers. If you create a raw pointer to a local variable and the variable goes out of scope, the pointer dangles. Dereferencing it is undefined behavior. The memory might be reused for something else. You might read a stack canary or a return address.
The optimizer assumes references follow the rules. If you break the rules, the optimizer breaks your code.
When to use 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 unsafe code in a small helper. Use unsafe when you're implementing a safe abstraction yourself, like a Vec, a linked list, or an allocator. Reach for safe references when lifetimes are simple; the unsafe alternative is rarely worth it. Reach for std::cell::Cell or RefCell when you need interior mutability without raw pointers. Reach for Arc or Rc when you need shared ownership.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about.