Unsafe Rust Explained and What the Keyword Really Promises
A guide to unsafe Rust: what the keyword allows, the contract it creates, how to wrap it in a safe API, how to check it with Miri and where it is actually needed.

The word unsafe is the most misunderstood keyword in Rust. People read it as "the compiler is turned off here", or as "this code is dangerous", and both readings produce bad decisions: avoiding unsafe when it is the right tool, and sprinkling it in when it is not. The accurate reading is narrower and more useful. unsafe is a marker that says: the compiler cannot verify the correctness of this code, so a human has checked the conditions instead. It does not disable the borrow checker, the type checker or any other check. It unlocks exactly five additional operations, and attaches to each a set of conditions that you promise are met.
This guide explains the five, the promise, and what happens when the promise is false, using Miri, the interpreter that detects undefined behavior, to show the actual diagnostics. Every program ran on Rust 1.99 (edition 2024) and on the nightly toolchain's Miri, and the output blocks are what they printed. By the end you should be able to read an unsafe block and say what it claims, write one with a comment that states the claim, and decide when you should not write one at all.
The five things unsafe unlocks
The reference lists them, and the list is short enough to memorize: dereference a raw pointer, call an unsafe function or method, access or modify a mutable static, implement an unsafe trait, and access a field of a union. The program below uses each once, correctly:
use std::ptr::{addr_of, addr_of_mut};
static mut LEGACY_COUNTER: u32 = 0;
/// 4. An unsafe trait: implementing it is a promise the compiler cannot check.
/// Here: `id()` never changes for the life of the value.
unsafe trait StableId {
fn id(&self) -> u64;
}
struct Ticket(u64);
unsafe impl StableId for Ticket {
fn id(&self) -> u64 {
self.0
}
}
/// 2. An unsafe function: the caller must pass a pointer valid for reading one `u32`.
///
/// # Safety
/// `ptr` must be non-null, aligned, and point to an initialized `u32`.
unsafe fn read_u32(ptr: *const u32) -> u32 {
unsafe { *ptr }
}
/// 5. A union: reading a field is only sound if those bytes are a valid value of that field's type.
#[repr(C)]
union IntOrFloat {
int: u32,
float: f32,
}
fn main() {
// 1. Dereference a raw pointer.
let x = 41u32;
let p = &x as *const u32;
println!("1. raw pointer read: {}", unsafe { *p });
// 2. Call an unsafe function.
println!("2. unsafe fn: {}", unsafe { read_u32(p) });
// 3. Access a mutable static, without ever creating a reference to it.
unsafe {
*addr_of_mut!(LEGACY_COUNTER) += 1;
println!("3. static mut: {}", *addr_of!(LEGACY_COUNTER));
}
// 4. Implementation of an unsafe trait.
println!("4. unsafe trait: ticket id {}", Ticket(9001).id());
// 5. Read a union field.
let u = IntOrFloat { float: 1.0 };
println!("5. union: 1.0f32 has bits {:#010x}", unsafe { u.int });
}1. raw pointer read: 41
2. unsafe fn: 41
3. static mut: 1
4. unsafe trait: ticket id 9001
5. union: 1.0f32 has bits 0x3f800000Each numbered item shows a different kind of promise.
Raw pointers, item 1. *const u32 is a pointer with none of a reference's guarantees. It may be null, dangling or misaligned, and the compiler does not track what it points to. Creating one is safe, because a pointer that is never dereferenced harms nothing. Reading through it is unsafe, and the promise is that it points to a valid, aligned, initialized value that nothing else is mutating.
Unsafe functions, item 2. An unsafe fn is a function with preconditions the compiler cannot check. The convention, enforced by Clippy, is a # Safety section in the doc comment stating them. read_u32 documents that ptr must be non-null, aligned and point to an initialized u32. The caller's unsafe block is the signature under that statement.
Mutable statics, item 3. A static mut is global mutable state with no synchronization, so any access can race with another thread. The example touches it with addr_of_mut! and addr_of!, which create raw pointers without ever making a reference to the static. That detail is not pedantry; the next section shows the compiler rejecting the obvious version.
Unsafe traits, item 4. A trait is unsafe when implementing it asserts something the compiler cannot verify. The standard library's Send and Sync are the famous ones. Here StableId promises that id() never changes, which unsafe code elsewhere could rely on.
Unions, item 5. Reading u.int after writing u.float reinterprets the bits of 1.0f32 as an integer, 0x3f800000. It is sound here because every u32 bit pattern is a valid u32. The same read as bool would not be, since a bool must be 0 or 1.
The edition 2024 changes you will meet
Rust 2024 tightened two rules that used to be traps. First, an unsafe fn no longer treats its whole body as an unsafe block. Each unsafe operation inside still needs its own unsafe { }, so the dangerous line is visible. Writing the old style now warns:
warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block
--> src/bin/opinunsafe.rs:2:5
|
2 | *ptr
| ^^^^ dereference of raw pointer
|
= note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior
note: an unsafe function restricts its caller, but its body is safe by default
= note: `#[warn(unsafe_op_in_unsafe_fn)]` (part of `#[warn(rust_2024_compatibility)]`) on by defaultSecond, creating a reference to a static mut is now a hard error. The straightforward counter that every tutorial used to show does not compile:
static mut COUNTER: u32 = 0;
fn bump() {
unsafe {
COUNTER += 1;
println!("counter is {}", COUNTER);
}
}error: creating a shared reference to mutable static
--> src/bin/staticmut.rs:6:35
|
6 | println!("counter is {}", COUNTER);
| ^^^^^^^ shared reference to mutable static
|
= note: shared references to mutable statics are dangerous; it's undefined behavior if the static is mutated or if a mutable reference is created for it while the shared reference lives
= help: use a type that relies on "interior mutability" instead
= note: `#[deny(static_mut_refs)]` (part of `#[deny(rust_2024_compatibility)]`) on by defaultprintln! takes a reference to its arguments, and a reference to a static mut that something else can modify is exactly the aliasing that the compiler can no longer rule out. The fix the message points to is not another unsafe incantation. It is to stop using static mut: an AtomicU32 for a counter, a Mutex or OnceLock for anything larger. Reserve static mut for embedded code that talks to hardware and has no other synchronization primitive, and use addr_of_mut! there, as the first program did.
What undefined behavior actually looks like
The promise attached to each unsafe operation is not a courtesy. Break it and you get undefined behavior, which does not mean "a crash" or "a wrong answer". It means the compiler is entitled to assume it never happens, and generates code that is wrong in any way at all, including appearing to work. Four small programs show the range. Each is a plausible mistake, and each was run under Miri, which checks every operation against the language's rules.
Using a pointer after its value is gone
fn main() {
let ptr: *const String = {
let s = String::from("hello");
&s as *const String
}; // `s` is dropped here
let text = unsafe { &*ptr };
println!("{text}");
}error: Undefined Behavior: constructing invalid value of type &std::string::String: encountered a dangling reference (use-after-free)
--> src/bin/uaf.rs:6:25
|
6 | let text = unsafe { &*ptr };
| ^^^^^ Undefined Behavior occurred hereMiri names the bug precisely: a dangling reference, a use after free. Now the same program built normally with cargo run --release. It compiled without a warning and printed this to the terminal:
�� @� �@�.� �� ��{��C ���|@� �1$�� �! �!@ ��# ...That is memory that used to hold the string, now read as text: arbitrary bytes. On another run, another machine or another compiler version it might print "hello", or crash, or leak a secret from freed memory. All of those are allowed. This is the real cost of the promise, and it is why the habit that matters is checking with Miri rather than running the program and seeing whether it looks right.
Skipping a bounds check
fn main() {
let v = vec![10u32, 20, 30];
let i = std::hint::black_box(3);
let x = unsafe { *v.get_unchecked(i) };
println!("{x}");
}get_unchecked is the unsafe version of indexing: no bounds check, and the promise that the index is in range. Index 3 into a three-element vector breaks it. In a debug build, recent compilers insert a check of the precondition and the program panics with unsafe precondition(s) violated: slice::get_unchecked requires that the index is within the slice, and Miri reports the same message. The release build, which has no such check, printed 0: the four bytes just past the vector, read as if they belonged to it. The program ran to completion and printed a plausible number. That is the dangerous case, an out-of-bounds read that looks fine, and it is the reason performance advice to "use get_unchecked in the hot loop" should be followed only with a benchmark that shows the check matters and a proof that the index is valid.
Breaking the aliasing rules
fn main() {
let mut value = 5;
let raw = &mut value as *mut i32;
let shared = &value; // a shared reference to the same place
unsafe { *raw = 6 }; // writing through the raw pointer invalidates `shared`
println!("{}", shared);
}error: Undefined Behavior: trying to retag from <552> for SharedReadOnly permission at alloc205[0x0], but that tag does not exist in the borrow stack for this location
|
= help: this indicates a potential bug in the program: it performed an invalid operation, but the Stacked Borrows rules it violated are still experimental
help: <552> was created by a SharedReadOnly retag at offsets [0x0..0x4]This one is subtle, because it involves no freed memory and no out-of-range access. The rule is that while a shared reference &value is live, nothing may modify value. The compiler is allowed to assume it, and to cache the value it read through shared. Writing through raw breaks the assumption. Miri tracks the permission each reference has to each byte (that is the "borrow stack" in the message) and reports the exact reference whose permission was revoked. The model it implements is still marked experimental, which means the precise rules may be refined, but the practical lesson is stable: do not hold a &T and mutate through another path.
Building a value that cannot exist
fn main() {
let byte = std::hint::black_box(3u8);
let flag: bool = unsafe { std::mem::transmute(byte) };
if flag { println!("yes"); } else { println!("no"); }
}error: Undefined Behavior: constructing invalid value of type bool: encountered 0x03, but expected a boolean
--> src/bin/boolbad.rs:3:31A bool is a byte that must be 0 or 1. transmute reinterprets bits without checking, and 3 is not a bool. The release build printed yes, and nothing in the output hints that the program is broken. Code like if flag is compiled assuming only two values exist, so a third can take either branch, or both, or jump somewhere unexpected. The safe way to write this conversion is byte != 0 or u8::try_from-style validation, and the general lesson is that transmute should be the last resort and always accompanied by a proof that every input bit pattern is valid for the output type.
Writing the promise down
A sound unsafe block is one where you can complete this sentence: "this is sound because ...". The convention in the standard library and in good crates is to write that sentence as a comment directly above the block, beginning // SAFETY:. Here is the standard example, a safe function built on two mutable slices carved from one:
fn main() {
let mut data = [1u32, 2, 3, 4];
let ptr = data.as_mut_ptr();
// Two disjoint mutable views carved from one allocation: sound, because they do not overlap.
let (a, b) = unsafe {
(std::slice::from_raw_parts_mut(ptr, 2), std::slice::from_raw_parts_mut(ptr.add(2), 2))
};
a[0] += 10;
b[1] += 20;
println!("{data:?}");
}[11, 2, 3, 24]Run under Miri, it reports no error. The two slices cover bytes 0 to 1 and 2 to 3 of the same array and do not overlap, so having two mutable views alive at once is sound, even though the borrow checker could never prove it. This is precisely what slice::split_at_mut does inside the standard library, and the right way to wrap the pattern in your own code is a small safe function that checks its inputs and contains the unsafe block.
pub fn split_in_two(data: &mut [u32], mid: usize) -> (&mut [u32], &mut [u32]) {
assert!(mid <= data.len(), "split point {mid} is past the end ({})", data.len());
let len = data.len();
let ptr = data.as_mut_ptr();
// SAFETY: `mid <= len` was just checked, so both ranges lie inside `data`.
// They are disjoint ([0, mid) and [mid, len)), so no byte is reachable
// through both mutable slices. The returned lifetimes are tied to `data`.
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}This is the shape of every well-made abstraction over unsafe code. The function is safe to call because its own checks, an assert! on the one input that could break the invariant, make the unsafe block's conditions hold for every possible argument. The SAFETY comment lists each condition and where it comes from. A reviewer can check the argument without reading the rest of the program, which is the entire point: unsafe code is contained, so the audit is local.
Run under Miri with three calls, it behaves as the argument predicts:
[100, 2, 300, 4, 5]
5 and 0
thread 'main' panicked at src/bin/split.rs:2:5:
split point 6 is past the end (5)The first call splits an array at 2 and mutates both halves. The second splits at the very end, which gives a full slice and an empty one, a valid edge case that a sloppy bounds check (mid < len) would have rejected. The third passes an invalid point, and the function panics with a message instead of constructing slices that run past the allocation. That panic is the whole difference between a sound abstraction and an unsound one: a caller using only safe code, however carelessly, cannot make this function cause undefined behavior. If removing the assert! made split_in_two(&mut data, 6) read memory past the array, the function would be unsound even though every caller's code compiles, and the bug would live in the unsafe block's module even if the symptom appeared far away.
Soundness is a promise about every caller
The term for this is soundness, and it is the standard to hold a safe function to: no sequence of safe calls may lead to undefined behavior. It is worth distinguishing it from correctness. A function that returns the wrong answer is incorrect, which is an ordinary bug. A safe function that can be tricked into a use after free is unsound, which is a bug of a different severity, because it breaks the guarantee that the whole language is sold on. Library authors take unsoundness reports seriously for that reason, and the Rust security advisory database lists them alongside vulnerabilities.
When you design an unsafe abstraction, ask three questions in order. What invariants must hold for the unsafe code inside to be valid? Which of those can the type system enforce, such as lifetimes tying a slice to its owner, or a private field that only your constructor can set? And for the rest, what check at the safe boundary makes the invariant true for every input? An invariant you cannot enforce at the boundary is a signal that the function itself should be unsafe, passing the obligation to the caller with a documented # Safety section.
The conditions people forget
Most unsafe bugs come from a small set of preconditions that look obvious once stated and are easy to miss in review. This table covers the operations you are most likely to write.
| Operation | Conditions that must hold |
|---|---|
*ptr and &*ptr | Non-null, aligned for the type, points to a live initialized value, and no conflicting access exists for the lifetime of any reference made |
slice::from_raw_parts(ptr, len) | ptr valid for len * size_of::<T>() bytes, aligned, initialized, and the total size at most isize::MAX, even for len = 0 where ptr must still be non-null and aligned |
ptr.add(n) | The result stays within the same allocation, or one byte past its end |
transmute::<A, B> | Same size, and every bit pattern of A that can occur is a valid B |
get_unchecked(i) | i is within bounds, as in the example |
String::from_utf8_unchecked | The bytes are valid UTF-8 |
mem::zeroed::<T>() | All-zero bytes are a valid T, which is false for references, NonZero*, Box and many enums |
Sending a type across threads with unsafe impl Send | Nothing in it is tied to the thread that made it, such as thread-local state or a non-atomic reference count |
Two entries repay a second look. The from_raw_parts rule about len = 0 surprises people: a dangling pointer is fine for an empty slice only if it is non-null and properly aligned, which is why NonNull::dangling() exists. And mem::zeroed is the classic trap, because it compiles for any type and is valid for far fewer than you would expect. Use MaybeUninit when you need uninitialized memory and Default for everything else.
Tools that find what you cannot see
Because the compiler cannot help inside an unsafe block, you use other tools. Miri (cargo +nightly miri test) runs your tests in an interpreter that checks every operation against the language rules; it caught all four bugs above with a line number. It is slow, 10 to 100 times slower than native, so run it on unit tests of the unsafe code, not on the whole integration suite. Address and thread sanitizers (RUSTFLAGS="-Zsanitizer=address" on nightly) work on real-speed builds and catch memory errors and data races that happen to occur in a run. Clippy has lints worth enabling in a crate that contains unsafe code: clippy::undocumented_unsafe_blocks insists on a SAFETY comment on each block, and missing_safety_doc insists on # Safety sections on public unsafe functions. And a crate with no unsafe code can say so to the compiler with #![forbid(unsafe_code)], which turns any later unsafe into an error, a useful guard for a library whose selling point is that it has none.
When to reach for unsafe, and when not to
Legitimate reasons are few and specific. Foreign function interfaces require it, because calling C is unsafe by definition: see calling C from Rust with extern and bindgen. Hardware access needs raw pointers and volatile reads. A few data structures, such as linked lists with back pointers, intrusive collections and some lock-free queues, cannot be expressed in safe Rust without a cost. And measured performance sometimes justifies it, but the order is important: write the safe version, benchmark, find the check that costs, and only then consider removing it.
Poor reasons are common. "The borrow checker is annoying" is a design signal, and the fix is usually indices instead of references, Rc<RefCell<T>> or restructuring ownership, not a pointer cast. "It would be faster" without a measurement is a guess, and bounds checks often cost less than a percent, because the optimizer removes many and the branch predictor handles the rest. And copying an unsafe snippet from a forum without being able to state why it is sound is how undefined behavior enters a codebase. If you cannot write the SAFETY comment, you do not yet know whether the code is correct.
The practical posture is to minimize the amount of unsafe, isolate it in small modules with safe public interfaces, write the justification beside each block, and run Miri on the tests. That is what makes Rust's claim true in practice: not that unsafe code does not exist, but that it is confined, labeled and checkable. The companion guide on writing a safe wrapper around unsafe code takes that approach through a worked example from start to finish.


