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.