Soundness, the word that sounds vague but isn't
You've written some unsafe Rust. Maybe you're calling into a C library, maybe you're writing a fast container, maybe you found a clever pointer trick on Stack Overflow. The compiler accepts it. Your tests pass. Job done?
Not quite. The Rust word for "is this unsafe code actually OK?" is soundness. A function (or a whole API) is sound if no caller can use it from safe code to cause undefined behaviour. If a caller can break things by feeding your function valid-looking input, your unsafe code is unsound, and the bug is yours, no matter how innocent the caller looked.
This is one of the few places in Rust where the compiler stops being your guardian. Inside unsafe, you're back in C-land: it's on you to know the rules and follow them. The good news is that Rust's rules are written down, finite, and learnable. This article walks through the ones you have to keep in your head every time you reach for unsafe.
The deal Rust offers you
Safe Rust gives you a strong promise: any program that compiles is free of undefined behaviour. Pointers can't dangle, references can't lie about what they point to, no two threads can race on the same data without a synchronization primitive. The borrow checker enforces this.
unsafe lets you opt out of a small subset of those checks, in exchange for taking on responsibility yourself. Inside unsafe { ... } you can:
- Dereference raw pointers (
*const T,*mut T). - Call other
unsafefunctions. - Read or write
static mutvariables. - Implement
unsafetraits. - Access fields of
unions.
Notice what it does NOT do: it does not turn off the borrow checker, the type checker, or any of the other compile-time checks. unsafe is narrow, and its job is to let you do the five things above without the compiler veto.
The big four invariants
The official rules live in the Nomicon and the language reference. The summary you have to internalise is:
1. References must point to valid, properly aligned memory of the right type, and they must satisfy aliasing rules. A &T must point to a live, initialized T. A &mut T must be the only way to access that location while it's alive. Even creating an aliased &mut is undefined behaviour, before you ever read or write through it.
2. No data races. Two threads, one of them writing, accessing the same non-atomic location without synchronization is undefined.
3. No reading uninitialized memory at typed level. You can't have a bool whose bit pattern isn't 0 or 1, a char that's an invalid code point, or any reference that's null. The bit patterns of types have rules; violating them is UB even if you never observe it.
4. No invariants of safe types broken. Vec<T> assumes its length is at most its capacity. String assumes its bytes are valid UTF-8. If you reach into one with raw pointers and break those, safe code that touches the result will misbehave.
These are the rules. Everything else is a special case of one of them.
A small example of an unsound API
Consider this innocent-looking function:
// Looks safe, isn't.
pub fn split_at_unchecked<T>(v: &[T], mid: usize) -> (&[T], &[T]) {
unsafe {
let ptr = v.as_ptr();
// Skip the bounds check that split_at does.
(
std::slice::from_raw_parts(ptr, mid),
std::slice::from_raw_parts(ptr.add(mid), v.len() - mid),
)
}
}
Looks reasonable. Now a caller, all in safe code:
fn main() {
let v = vec![1, 2, 3];
// Pass mid = 100. The function returns slices into out-of-bounds memory.
let (a, b) = split_at_unchecked(&v, 100);
println!("{:?} {:?}", a, b); // Undefined behaviour.
}
The caller wrote no unsafe, did nothing wrong by their reading of the rules, and triggered UB. That makes split_at_unchecked unsound. The fix is either to add the bounds check (making the function fully safe), or to mark the function unsafe and document the precondition:
// Now the caller must say `unsafe { ... }` and is contractually responsible
// for checking that mid <= v.len() before calling.
pub unsafe fn split_at_unchecked<T>(v: &[T], mid: usize) -> (&[T], &[T]) {
let ptr = v.as_ptr();
(
std::slice::from_raw_parts(ptr, mid),
std::slice::from_raw_parts(ptr.add(mid), v.len() - mid),
)
}
The general principle: a public safe function that wraps unsafe internals must validate its inputs, OR shift the responsibility to the caller by marking the wrapper unsafe too. Anything else is unsound.
Aliasing: the trickiest one
Aliasing rules are where most subtle bugs live. The model Rust commits to (informally; the formal model is still evolving) is roughly: at any moment, for any byte of memory, either there is exactly one &mut reference to it (and nothing else), or there are zero or more & references. Crossing those streams is UB.
Crucially, "exists" means "the reference value exists in your program," not "you've used it." Creating two &mut to the same place is UB even if you never read or write through one of them:
let mut x = 5;
let r1 = &mut x;
let r2 = &mut x; // already UB the moment this line executes
In safe Rust the borrow checker prevents this. In unsafe Rust, with raw pointers, you have to track it yourself. The Box::leak / Box::from_raw round-trip, certain FFI patterns, and self-referential structs all flirt with this rule and require care.
The Miri interpreter (run with cargo +nightly miri test) is the tool to reach for if you're unsure. Miri actually checks aliasing and UB at runtime in a strict model.
"Unsafe" doesn't mean "actually does something dangerous"
A confusion worth clearing up: unsafe { ... } does not mean "this code is dangerous." It means "this code makes claims the compiler is choosing to trust." A correct unsafe block does not, in fact, do anything wrong. Sound unsafe code is just code that holds up its end of the contract.
The compiler error you get if you forget the keyword:
error[E0133]: dereference of raw pointer is unsafe and requires unsafe block
--> src/main.rs:5:13
|
5 | let v = *ptr;
| ^^^^ dereference of raw pointer
Adding unsafe { ... } makes the error go away but does not make the code correct. That part is your job.
Working with unsafe in practice
A few practical rules that experienced Rust programmers follow:
- Keep
unsafeblocks small. The smaller they are, the easier the audit. - Document each
unsafeblock with a// SAFETY:comment that explains exactly which invariants you're upholding and why. - When you write an
unsafe fn, document each precondition the caller must guarantee. - Wrap unsafe internals in safe APIs that validate everything the unsafe code assumes.
- Run
cargo +nightly miri teston test suites that exercise unsafe code.
The community convention is so strong now that a missing // SAFETY: comment is treated like a missing semicolon: the code might compile, but it won't pass code review.
When to reach for unsafe
You probably won't, most of the time. The standard library and crates ecosystem already wrap most of the genuinely-unsafe operations behind safe interfaces. Vec, Box, Rc, Arc, Mutex, Cell, RefCell, all of them are unsafe inside and safe outside. Use them.
You reach for unsafe yourself when: calling C, doing bit-level work the compiler can't otherwise express, building a primitive container that doesn't already exist, or interacting with hardware. In each case, treat the unsafe block as a small contract you've signed in blood.