The standard library, FFI and high-performance data structures rest on unsafe code; using it soundly is an expert skill.
unsafe does not turn off the borrow checker. It unlocks five extra operations whose safety the compiler cannot verify:
- dereferencing a raw pointer (
*const T,*mut T), - calling an
unsafe fn(including foreign functions), - reading or writing a
static mut, - implementing an
unsafe traitsuch asSendorSync, - reading fields of a
union.
An unsafe block is your promise that you have upheld the invariants the compiler normally checks. If you are wrong, the result is undefined behaviour (UB): not just a crash, but permission for the optimiser to assume impossible things and miscompile surrounding safe code. Examples of UB: creating a dangling or misaligned reference, two live &mut to the same data, reading uninitialised memory, a bool that is not 0 or 1, data races.
Raw pointers may be null, dangling or aliased; creating them is safe, only dereferencing is unsafe. Use &raw const x / &raw mut x (stable since 1.82) to get a pointer without first creating a reference, which matters for unaligned or uninitialised fields.
The goal is a safe abstraction: a small unsafe core behind a safe API whose every possible use is sound, like Vec or split_at_mut. Soundness is a property of the module, since safe code in the same module can break invariants that the unsafe code relies on. Document each block with a // SAFETY: comment saying why the requirements hold, and each unsafe fn with a # Safety doc section listing caller obligations.
The 2024 edition tightens the rules: operations inside an unsafe fn need their own unsafe {} block (the unsafe_op_in_unsafe_fn lint warns by default), extern blocks must be written unsafe extern, and attributes such as no_mangle become #[unsafe(no_mangle)].
Test unsafe code under Miri (cargo +nightly miri test), an interpreter that detects many kinds of UB.
Example
use std::slice::from_raw_parts_mut;
fn split_at_mut<T>(s: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let (ptr, len) = (s.as_mut_ptr(), s.len());
assert!(mid <= len, "mid out of bounds"); // the safe API enforces the invariant
// SAFETY: mid <= len, so [0, mid) and [mid, len) are in bounds and disjoint,
// and both borrow from `s`, which outlives the returned slices.
unsafe { (from_raw_parts_mut(ptr, mid), from_raw_parts_mut(ptr.add(mid), len - mid)) }
}
/// # Safety
/// `p` must be non-null, aligned and point to an initialised `u32`.
unsafe fn read_u32(p: *const u32) -> u32 {
unsafe { *p } // 2024: an explicit unsafe block even inside an unsafe fn
}
fn main() {
let mut data = [1, 2, 3, 4, 5];
let (a, b) = split_at_mut(&mut data, 2);
a[0] = 10; b[0] = 30;
println!("{data:?}"); // [10, 2, 30, 4, 5]
let x = 7u32;
let p = &raw const x; // raw pointer without an intermediate reference
println!("{}", unsafe { read_u32(p) }); // SAFETY: p points to a live u32 -> 7
}Remember
unsafeenables five operations; it does not disable borrow checking- Wrap unsafe code in safe APIs that are sound for every possible caller
- Write
// SAFETY:comments and test with Miri - 2024 edition: unsafe blocks inside unsafe fn,
unsafe extern,#[unsafe(no_mangle)]
Watch out for
- Assuming code that seems to work is fine; UB can appear only under optimisation or on another compiler version.