When safe Rust hits a wall
You are building a high-performance ring buffer for a network packet parser. You have a pre-allocated chunk of memory. You need to write data directly into a specific offset without copying, and you need to read from another offset simultaneously. The safe Vec API gives you iterators and bounds checks, but it won't let you mutate the buffer at arbitrary indices while holding other references. The borrow checker blocks you. It sees potential aliasing and says, "I cannot prove this won't crash."
You need to tell the compiler, "I have checked the math. I know the offsets are valid. I know the lifetimes don't overlap. Let me do this." That is the unsafe block. It is not a magic wand that turns off all safety. It is a specific set of keys that unlocks operations the compiler cannot verify. You are taking responsibility. The compiler assumes you are right. If you are wrong, you get undefined behavior. The program might crash, corrupt data, or appear to work until a specific optimization level changes the assembly.
The four operations inside a block
An unsafe block allows four specific operations that are forbidden in safe code. There is a fifth unsafe operation in Rust, implementing an unsafe trait, but that uses unsafe impl syntax at the item level. You cannot put unsafe impl inside a block. The block handles runtime actions.
The four operations are dereferencing raw pointers, calling unsafe functions, accessing mutable static variables, and reading or writing union fields. Each of these bypasses a specific guarantee the compiler normally enforces. Raw pointers bypass lifetime and validity checks. Unsafe functions bypass the compiler's ability to verify preconditions. Mutable statics bypass exclusive access guarantees. Unions bypass type safety by allowing the same bits to be interpreted as different types.
The unsafe keyword generates zero machine code. It is purely a compile-time annotation. At runtime, the CPU does not know you used unsafe. The block is a contract between you and the compiler. You promise that the code inside upholds the safety invariants that the compiler cannot check. The compiler promises to generate code based on that promise.
Minimal example
This example demonstrates the four operations allowed inside an unsafe block. Real code should wrap these operations in safe abstractions with clear contracts. This snippet exists only to show the syntax and the scope of what the block permits.
use std::mem::MaybeUninit;
/// A union to demonstrate type punning.
/// Unions allow storing different types in the same memory location.
union MemoryBlock {
integer: i32,
float: f32,
}
/// An unsafe trait to demonstrate the distinction.
/// Implementing this requires `unsafe impl`, not a block.
unsafe trait LowLevelAccess {}
unsafe impl LowLevelAccess for i32 {}
/// An unsafe function that requires the caller to uphold invariants.
/// The compiler cannot check if the caller satisfies the requirements.
unsafe fn raw_write(ptr: *mut i32, value: i32) {
// The function assumes ptr is valid and aligned.
// It does not check. The caller must guarantee this.
*ptr = value;
}
/// A mutable static variable.
/// Accessing this requires unsafe because it can be aliased.
static mut GLOBAL_STATE: i32 = 0;
fn main() {
// SAFETY: This block demonstrates the four unsafe operations.
// In production code, each operation would have its own specific
// safety proof based on the surrounding logic.
unsafe {
// 1. Dereference a raw pointer.
// Raw pointers do not guarantee validity or alignment.
// The compiler trusts you that this pointer points to valid memory.
let ptr: *const i32 = &42;
let value = *ptr;
println!("Pointer dereference: {}", value);
// 2. Call an unsafe function.
// The function signature declares it unsafe.
// The block asserts you have satisfied its preconditions.
let mut buffer = 0i32;
raw_write(&mut buffer as *mut i32, 99);
println!("Unsafe fn result: {}", buffer);
// 3. Access a mutable static variable.
// Static mut allows mutation but breaks exclusive access guarantees.
// Concurrent access causes undefined behavior.
GLOBAL_STATE += 1;
println!("Static mut access: {}", GLOBAL_STATE);
// 4. Access a union field.
// Unions allow reading the same bits as different types.
// You must ensure the active field matches the read type.
let block = MemoryBlock { integer: 0x41200000 };
let f = block.float;
println!("Union field read: {}", f);
}
}
Treat the // SAFETY: comment as a proof. If you cannot write the invariants that justify the block, you do not have a proof. The community expects these comments to document exactly why the code is safe, not just that it is safe.
What happens at compile and runtime
When the compiler encounters an unsafe block, it stops checking the specific unsafe operations inside. It still checks types, syntax, and other safety rules. If you try to dereference a raw pointer outside the block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe block). The block is the only place where the compiler allows these operations.
At runtime, the unsafe block disappears. The generated assembly is identical to what you would write in C or C++. There is no overhead. There are no runtime checks added. The block removes checks. If your pointer is null, the CPU attempts to dereference null. The operating system kills the process with a segmentation fault. If your pointer is dangling, the CPU reads garbage. If you have a data race, the CPU interleaves memory operations in ways that corrupt state.
Undefined behavior is the core risk. Unsafe code can trigger undefined behavior. Undefined behavior is not an error. It is a logical contradiction. The compiler uses the absence of undefined behavior to optimize code. If you trigger undefined behavior, the compiler's optimizations might delete your null checks because it assumes null checks are unnecessary. Your code might pass all tests and then silently corrupt memory in production. The compiler assumes you are correct. If you are wrong, the program breaks reality.
Realistic example: wrapping raw memory
The goal of unsafe is to build safe abstractions. You use unsafe internally to implement a type or function that provides a safe API. The unsafe block should be small and isolated. The community calls this the minimum unsafe surface rule. You expose a safe interface that enforces the invariants, and the unsafe block sits behind that interface.
This example shows a function that converts a raw pointer and length into a slice. The function is safe because it wraps the unsafe operation and documents the contract. The caller of this function must ensure the pointer is valid. The function itself uses unsafe to perform the conversion.
use std::slice;
/// Converts a raw pointer and length into a slice.
///
/// # Safety
/// The caller must ensure that `ptr` is valid for reads of `len` elements.
/// The pointer must be properly aligned. The memory must not be mutated
/// while the slice is alive.
pub unsafe fn buffer_to_slice(ptr: *const u8, len: usize) -> &'static [u8] {
// SAFETY: The function contract requires the caller to provide a valid
// pointer and length. We trust that contract. The caller is responsible
// for upholding these invariants.
slice::from_raw_parts(ptr, len)
}
fn main() {
// Simulate receiving a buffer from C or an allocator.
let data = [1u8, 2, 3, 4, 5];
let ptr = data.as_ptr();
let len = data.len();
// SAFETY: We created the pointer from a valid array.
// The array lives in the same scope, so the pointer is valid.
let slice = unsafe { buffer_to_slice(ptr, len) };
println!("Slice: {:?}", slice);
}
Wrap the unsafe in a safe function. Expose the contract, hide the danger. The // SAFETY: comment in the doc comment tells the caller what they must guarantee. The // SAFETY: comment inside the block tells the reader why the code satisfies the requirements of the unsafe operation. This separation makes the code auditable.
Pitfalls and the cost of lying
The biggest pitfall is lying in the // SAFETY: comment. If the comment claims a pointer is valid but the code does not ensure it, you have undefined behavior. Reviewers check these comments. If the proof does not hold, the code is rejected. The comment is not optional. It is the documentation of the invariant.
Mutable static variables are a trap. static mut allows mutation, but it breaks the exclusive access guarantee of &mut. You can create multiple mutable references to a static mut via raw pointers. This causes data races. Data races are undefined behavior in Rust. Use std::sync::Mutex or atomic types instead. If you must use static mut, isolate it in a single unsafe block and never expose mutable references.
Another pitfall is assuming unsafe makes code safe. It does the opposite. It removes compiler guarantees. You must provide the guarantees manually. If you forget a bounds check, the compiler will not catch it. If you forget to initialize memory, the compiler will not warn you. The burden shifts entirely to you.
Clippy helps catch some unsafe patterns. The clippy::undocumented_unsafe_blocks lint warns if an unsafe block lacks a // SAFETY: comment. Enable this lint in your project. It enforces the convention that every unsafe block must have a justification.
Undefined behavior is a landmine. One lie in your assumptions and the whole program is compromised. The compiler may optimize based on your lie, deleting checks that would have caught the error. Test thoroughly, but remember that tests cannot prove the absence of undefined behavior. Code review and formal reasoning are required.
Decision: when to use unsafe
Use unsafe blocks when you must dereference raw pointers to access memory that safe references cannot reach, such as in a custom allocator, a low-level parser, or when interfacing with C libraries.
Use unsafe blocks when calling unsafe functions provided by the standard library or external crates, ensuring you satisfy their documented preconditions and wrap the call in a safe abstraction.
Use unsafe blocks when accessing mutable static variables, though you should prefer std::sync::Mutex or atomic types to avoid data races and undefined behavior.
Use unsafe blocks when reading or writing union fields to interpret the same bits as different types, ensuring the active field matches the read type to avoid undefined behavior.
Reach for safe abstractions whenever possible. If you can express your logic with &T, &mut T, Box, Vec, or iterators, do it. The compiler protects you for free. The borrow checker prevents data races, null pointer dereferences, and use-after-free errors. Safe code is easier to reason about, easier to maintain, and less prone to subtle bugs.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Unsafe code requires mental tracking of invariants that the compiler otherwise handles. Minimize the surface area. Isolate the danger. Build safe walls around it.