Rust’s unsafe keyword is less a backdoor to C-style memory corruption and more a finely tuned scalpel for operations that the compiler can’t statically verify but are demonstrably safe if you uphold certain invariants. The most surprising thing about unsafe is that it doesn’t magically turn off Rust’s safety guarantees; it simply shifts the burden of proof from the compiler to you, the programmer.
Let’s see unsafe in action with a common pattern: creating a Vec from a raw pointer and length, but only after ensuring the memory is valid.
use std::slice;
use std::mem;
fn create_vec_from_raw_parts(ptr: *const u8, len: usize) -> Vec<u8> {
// This is the unsafe operation: assuming the pointer points to `len` valid u8s.
let slice: &[u8] = unsafe {
slice::from_raw_parts(ptr, len)
};
// We can then clone this slice into a new Vec.
// The safety of this operation depends entirely on the caller ensuring
// that `ptr` is valid for `len` bytes and that the memory remains valid
// for the lifetime of the `slice` (which is implicitly tied to the
// scope of this function and the returned Vec).
slice.to_vec()
}
fn main() {
let mut buffer = vec![0u8; 10];
let ptr = buffer.as_ptr();
let len = buffer.len();
// Now, let's pretend we got these parts from somewhere else and need to
// reconstruct a Vec, but we *know* they are valid.
// This is where `unsafe` comes in.
let safe_vec = create_vec_from_raw_parts(ptr, len);
println!("Created Vec: {:?}", safe_vec);
// Important: The original `buffer` still owns the memory.
// `safe_vec` is a *copy*. If `buffer` were dropped here, the memory
// `safe_vec` refers to would become invalid, leading to UB if `safe_vec`
// were used. The `create_vec_from_raw_parts` function *copies* the data
// via `to_vec()`, which is why it's safe in this specific example *after*
// the slice is created.
// If we had done `Vec::from_raw_parts(ptr as *mut u8, len, len)`,
// we would be taking ownership, and dropping `buffer` would be UB.
}
The core problem unsafe addresses is when you need to interact with the outside world (like C libraries), perform low-level memory manipulation that the borrow checker can’t follow, or implement data structures that rely on invariants not expressible in safe Rust.
Internally, Rust’s safety comes from its strict rules: no data races, no use-after-free, no double-free, no dangling pointers, and no buffer overflows in safe code. When you step into unsafe, you are essentially telling the compiler: "Trust me, I’ve verified these operations are safe according to the rules of memory safety, even though you can’t prove it." You are then responsible for upholding these invariants yourself.
The five "unsafe super-traits" (as they’re sometimes called) that unsafe code must uphold are:
- Dereferencing a raw pointer:
*const Tor*mut Tcan be dereferenced. The pointer must be valid, non-null, and aligned forT. It must also point to memory that is initialized and accessible. - Calling an
unsafefunction or method: This includes functions likestd::slice::from_raw_parts,std::mem::transmute, and foreign function interface (FFI) calls. - Accessing or modifying a mutable static variable: These are global mutable states, inherently prone to data races if not managed carefully.
- Implementing
unsafetraits: Traits marked withunsaferequire the implementor to guarantee certain safety properties. - Accessing fields of
unions: Unions can have multiple interpretations of the same memory; accessing the wrong field is undefined behavior.
A common pattern for managing unsafe operations is to wrap them in a safe abstraction. For instance, you might create a MySafeVec type that internally uses Vec but exposes only safe methods. When those methods need to perform unsafe operations (like resizing by directly manipulating memory), they do so within a private unsafe block, after performing checks that guarantee the operation’s safety. This limits the scope of unsafe and makes the code easier to reason about.
The key to unsafe is maintaining invariants. For example, if you’re implementing a linked list using raw pointers, your unsafe code must ensure that no pointer ever becomes dangling, that memory is always freed exactly once, and that cycles don’t prevent deallocation. The compiler won’t stop you from creating a cycle, but your unsafe implementation must account for it to avoid leaks or double-frees.
Consider the std::slice::from_raw_parts function. The invariant it relies on is that the ptr is valid for len bytes. If you pass a null pointer, a pointer to uninitialized memory, or a pointer where ptr + len goes out of bounds, you’ve broken the invariant, and the resulting slice can lead to undefined behavior when dereferenced. The safety of slice.to_vec() in the example above is because it copies the data from the valid slice. If, instead, we had used Vec::from_raw_parts(ptr as *mut u8, len, len), we would be taking ownership of the memory. If the original buffer was dropped before the Vec created from from_raw_parts, that Vec would then point to deallocated memory, leading to a use-after-free.
The danger of unsafe isn’t that it’s inherently bad, but that it’s a potent tool that, when misused, can bypass all of Rust’s compile-time safety checks. The compiler cannot help you debug memory safety issues that arise from unsafe code. You’re on your own.
The next thing you’ll likely grapple with is how to implement unsafe traits, which requires a deep understanding of the trait’s safety contract.