Unsafe Rust
Rust's safety guarantees are one of its most celebrated features. But those
guarantees come from checks the compiler performs at compile time — and there are
situations where you know something is safe that the compiler cannot prove. That is
where unsafe comes in.
An unsafe block or function does not disable the borrow checker. It grants
access to a small, controlled set of additional capabilities that the compiler
ordinarily forbids. Everything else — ownership, borrowing, lifetimes — still applies
in full.
What unsafe Actually Means
Writing unsafe is a promise from you to the compiler: "I have verified that this
code upholds the safety invariants that Rust normally enforces automatically." The
compiler trusts you and stops checking those specific invariants — but only within the
unsafe block.
Safe Rust code can call into unsafe Rust, and safe abstractions can be built on top
of unsafe foundations. The standard library does this constantly — Vec,
HashMap, and Arc all use unsafe internally while exposing a completely safe
public API.
unsafe code can cause undefined behaviour: memory corruption, data races, and crashes that are hard to reproduce. The goal is not to avoid unsafe forever, but to keep its surface area as small as possible and to document every invariant carefully.The Five Unsafe Superpowers
There are exactly five things you can do inside an unsafe block that you cannot do
in safe Rust:
Dereference raw pointers (
*const Tand*mut T)Call unsafe functions and methods
Access or modify mutable static variables
Implement unsafe traits
Access fields of unions
Everything else in an unsafe block is still subject to normal Rust rules. You
cannot ignore lifetime errors or borrow violations just by wrapping code in
unsafe.
Raw Pointers
Raw pointers are written *const T (immutable) and *mut T (mutable). Unlike
references, raw pointers:
- May be null or dangling
- Do not carry a lifetime
- Are not checked by the borrow checker
- Can have multiple mutable pointers to the same location at once
Creating a raw pointer is safe; dereferencing it requires unsafe.
fn main() {
let mut value = 5;
// Create raw pointers from references — safe, no unsafe block needed
let r1 = &value as *const i32;
let r2 = &mut value as *mut i32;
// Dereference — requires unsafe
unsafe {
println!("r1 = {}", *r1);
*r2 = 42;
println!("r2 = {}", *r2);
}
// Create a raw pointer to an arbitrary address (dangerous!)
let dangling = 0x12345usize as *const i32;
// Dereferencing 'dangling' would be undefined behaviour — don't do it
println!("value is now: {}", value);
}r1 = 5 r2 = 42 value is now: 42
Calling Unsafe Functions
A function marked unsafe fn signals that calling it requires the caller to uphold
certain invariants that the compiler cannot check. Calling such a function requires
an unsafe block.
// Declare an unsafe function — the caller must guarantee 'ptr' is valid
unsafe fn dangerous_read(ptr: *const i32) -> i32 {
*ptr
}
fn main() {
let x = 99;
let p = &x as *const i32;
// SAFETY: p was just created from a valid reference; it cannot be null or dangling.
let value = unsafe { dangerous_read(p) };
println!("read: {}", value);
}read: 99
The standard library's slice::from_raw_parts is a good real-world example of an
unsafe function — it creates a slice from a raw pointer and a length, and it is your
responsibility to ensure both are valid.
fn main() {
let data = vec![1_i32, 2, 3, 4, 5];
// SAFETY: data.as_ptr() is valid and data.len() is correct.
let slice = unsafe {
std::slice::from_raw_parts(data.as_ptr(), data.len())
};
println!("slice: {:?}", slice);
}slice: [1, 2, 3, 4, 5]
Mutable Static Variables
Rust allows global static variables. Immutable statics are perfectly safe — they
never change. Mutable statics (static mut) are a different matter: any code can
mutate them, creating the risk of data races in a multi-threaded program. Reading or
writing a static mut therefore requires unsafe.
static HELLO: &str = "Hello, world!"; // immutable static — safe to access
static mut COUNTER: u32 = 0; // mutable static — requires unsafe to touch
fn increment() {
// SAFETY: This program is single-threaded; no data race is possible.
unsafe {
COUNTER += 1;
}
}
fn main() {
println!("{}", HELLO);
increment();
increment();
increment();
// SAFETY: Single-threaded; no concurrent access.
unsafe {
println!("COUNTER = {}", COUNTER);
}
}Hello, world! COUNTER = 3
std::sync::atomic types orMutex<T> instead of static mut. They provide the same global state without the data-race risk.Implementing Unsafe Traits
Some traits are marked unsafe trait because implementing them incorrectly can
cause undefined behaviour. The two you will encounter most are Send and Sync:
Send: safe to transfer ownership of the type to another threadSync: safe to share a reference to the type across threads (&TisSend)
The compiler implements these automatically for most types. You only need to write
unsafe impl when you have a type that the compiler cannot verify but you know is
safe to send or share.
use std::sync::Arc;
// A raw pointer wrapper — the compiler won't auto-impl Send/Sync
struct SafeWrapper(*mut i32);
// SAFETY: We guarantee that SafeWrapper is only ever accessed from one thread
// at a time, making it safe to transfer across thread boundaries.
unsafe impl Send for SafeWrapper {}
fn main() {
let mut value = 42;
let wrapper = SafeWrapper(&mut value as *mut i32);
// Now we can move wrapper into a thread
let handle = std::thread::spawn(move || {
// SAFETY: We own wrapper and no other thread has access.
unsafe { println!("value from thread: {}", *wrapper.0); }
});
handle.join().unwrap();
}value from thread: 42
The SAFETY Comment Convention
Every unsafe block should be accompanied by a // SAFETY: comment that explains
why the code is actually safe. This is a community convention, not a compiler
requirement, but it is essential for maintainability.
fn split_at_middle(slice: &[i32]) -> (&[i32], &[i32]) {
let mid = slice.len() / 2;
let ptr = slice.as_ptr();
// SAFETY:
// - ptr is non-null because it comes from a valid slice.
// - mid <= slice.len(), so ptr.add(mid) is within or one past the end.
// - The two sub-slices together cover exactly the original slice.
// - The lifetime of both sub-slices is tied to 'slice', preventing use-after-free.
unsafe {
(
std::slice::from_raw_parts(ptr, mid),
std::slice::from_raw_parts(ptr.add(mid), slice.len() - mid),
)
}
}
fn main() {
let data = [1, 2, 3, 4, 5, 6];
let (left, right) = split_at_middle(&data);
println!("left: {:?}", left);
println!("right: {:?}", right);
}left: [1, 2, 3] right: [4, 5, 6]
Why the Standard Library Uses Unsafe
It is instructive to know that the standard library's most-used types are all built on
unsafe:
Vec<T>— manages a heap-allocated buffer with raw pointers internally; methods likepushandpopuse unsafe to manipulate the pointer and length directly.String— a thin wrapper aroundVec<u8>that upholds the UTF-8 invariant; certain conversions use unsafe to avoid redundant validation.HashMap<K, V>— uses unsafe for its hash table implementation to achieve performance while hiding all pointer manipulation behind a safe interface.Arc<T>— uses raw atomic operations (unsafe) to manage the reference count; the public API is completely safe.Box<T>— the fundamental heap allocation primitive; its implementation uses raw pointer allocation and deallocation.
The pattern is always the same: unsafe internals, safe public API. This is the gold standard for writing unsafe code — encapsulate the invariants in a small, well-tested module so that callers never need to think about them.
When You Might Need Unsafe
Scenario | Why unsafe is needed |
|---|---|
FFI with C libraries | C has no safety guarantees; all C calls are unsafe |
Performance hot paths | Skip bounds checks after you have manually verified bounds |
Low-level memory tricks | Reinterpret bytes, work with raw memory layouts |
Implementing Send/Sync | Compiler cannot verify thread safety of some types |
Custom allocators | Must manipulate raw memory directly |
Embedded / OS code | Must write to hardware-mapped memory addresses |
Minimising Unsafe Surface Area
The best strategy with unsafe code is to keep it as small and isolated as possible:
Put unsafe code in its own module or function with a narrow, well-defined interface.
Write extensive tests for the unsafe module, including edge cases and boundary conditions.
Document every invariant with a
// SAFETY:comment so future maintainers understand the contract.Consider using crates like
bytemuck,zerocopy, ormemoffsetthat provide safe abstractions over common unsafe patterns.Run
cargo mirito check your unsafe code for undefined behaviour under the MIRI interpreter.
// SAFETY: comment before the block, not after.