Unsafe Rust starts where Rust no longer guarantees safety
First, we need to clear up an old misunderstanding right at the start.
Does unsafe in Rust mean “turning security off”?
No. Not at all.
Rust is, by nature, a language built on contracts.
The borrow checker, lifetimes, and aliasing rules are all tools that let Rust confidently say one very important sentence:
If your code compiles, it’s memory-safe.
But that sentence only holds as long as Rust can actually verify things.
At some point, Rust honestly steps back.
Starting from a completely safe point (Safe Rust)
Let’s start with a fully safe example:
fn main() {
let x = 42;
let r = &x;
println!("r = {}", r);
}
Nothing special happens here. The borrow checker provides a few simple but vital guarantees:
xstays alive for as long as it’s being used- the reference is valid
- there’s no dangerous aliasing
- and most importantly: no Undefined Behavior is possible
Here, unsafe doesn’t mean anything yet.
Everything is inside the language’s contract.
Creating a raw pointer without unsafe
Now let’s go one step further:
fn main() {
let x = 42;
let raw = &x as *const i32;
println!("raw pointer address: {:?}", raw);
}
This part usually delivers the first mental shock.
We created a raw pointer, yet we wrote no unsafe.
Creating a raw pointer in Rust is not unsafe. Why? Because we still haven’t done anything that forces Rust to guarantee anything.
A raw pointer means this very important sentence:
From here on, Rust won’t check anything.
No lifetimes, no aliasing, no validation that the memory is valid. But nothing dangerous has happened yet, because we haven’t used the pointer.
The first unsafe block (this is where trouble starts)
The problem starts exactly when we try to use it!
fn main() {
let x = 42;
let raw = &x as *const i32;
unsafe {
println!("value via raw pointer = {}", *raw);
}
}
Dereferencing a raw pointer is officially stepping into a region where Rust no longer takes responsibility.
Why does this line need unsafe?
Because Rust no longer knows:
- whether this pointer still points to valid memory
- whether that memory has been dropped
- whether there’s dangerous mutable aliasing happening at the same time
So Rust offers you this contract:
If you want to do this, you have to guarantee it yourself.
And that’s the real definition of unsafe:
unsafemeans: “I guarantee this is safe.”
No more, no less.
Undefined Behavior, the worst thing that can happen in Rust
The biggest danger of unsafe isn’t a crash.
It isn’t panic.
It isn’t even a segmentation fault.
The real danger is something Rust calls Undefined Behavior.
An example that looks “harmless,” but is completely wrong
fn main() {
let raw_ptr: *const i32;
{
let x = 123;
raw_ptr = &x as *const i32;
}
unsafe {
println!("value = {}", *raw_ptr);
}
}
This code compiles. No warnings. No errors.
But from Rust’s perspective, this code is completely wrong.
Why?
xis created inside an inner scope- after that scope ends, the variable is dropped
raw_ptrnow points to memory that no longer belongs to the program
When you run it:
cargo run
It might:
- print the correct number
- print the wrong number
- crash
- or “for now,” appear to work
And that “for now” is the most dangerous part.
UB means Rust promises you nothing
This isn’t panic. Rust makes no promise about what will happen.
UB means: “anything is allowed.”
The compiler can assume this situation never happens, and based on that assumption, it can optimize and rewrite surrounding code. The result can be completely unpredictable.
Comparing it to panic (a crucial difference)
fn main() {
let v = vec![1, 2, 3];
println!("{}", v[10]);
}
Let’s pay closer attention to how this snippet panics:
- you get an error message
- you get a stack trace
- the program stops
It’s bad, but it’s safe.
A second UB example: mutable aliasing
fn main() {
let mut x = 10;
let r1 = &x as *const i32;
let r2 = &mut x as *mut i32;
unsafe {
*r2 = 20;
println!("{}", *r1);
}
}
Here, at the same time, we have:
- an immutable reference
- and a mutable reference
This is exactly what the borrow checker always prevents. But raw pointers bypass the borrow checker, not the laws of memory management.
Rust stays silent. But we have UB.
The borrow checker isn’t your enemy; it’s your guard.
Engineer unsafe
If unsafe were only danger, Rust wouldn’t provide it.
unsafe is a tool for building abstractions, not a shortcut.
The goal isn’t for unsafe to spread everywhere.
The goal is for unsafe to be contained.
Building a safe abstraction with internal unsafe
Scenario: We want to build a simple wrapper that holds a raw pointer internally, but is completely safe on the outside.
struct MyBox<T> {
ptr: *mut T,
}
Here, Rust provides no guarantees. The responsibility is on us.
A safe constructor
impl<T> MyBox<T> {
fn new(value: T) -> Self {
let boxed = Box::new(value);
let ptr = Box::into_raw(boxed);
Self { ptr }
}
}
From this moment on:
- Rust will no longer drop this allocation
- we are responsible for freeing it
First invariant:
ptralways points to valid heap memory
Safe access to the data
impl<T> MyBox<T> {
fn get(&self) -> &T {
unsafe {
&*self.ptr
}
}
}
Why unsafe?
Because dereferencing a raw pointer is unsafe.
Why is it safe here? Because we’ve defined the invariants ourselves.
Second invariant:
As long as
MyBoxis alive,ptrwon’t dangle
Freeing memory with Drop
impl<T> Drop for MyBox<T> {
fn drop(&mut self) {
unsafe {
drop(Box::from_raw(self.ptr));
}
}
}
If we don’t write this, we leak memory. If we do it twice, we get UB.
Third invariant:
ptris freed exactly once
External usage (fully safe)
fn main() {
let b = MyBox::new(42);
println!("value = {}", b.get());
}
There is no unsafe here.
The consumer can’t even make a mistake.
Final summary
unsafe Rust is still Rust.
But Rust won’t protect you anymore.
unsafe is a tool of power, not convenience.
If you don’t fully understand the invariants, don’t write unsafe — even if your code “works.”
unsafeshould be like radioactive material: minimal, contained, and clearly labeled
Comments
Be the first to share your thoughts.