unsafe — taking the proof back
Since Lesson 15 our safe code has leaned on library types — RefCell, Mutex, Vec, Pin — whose insides do things the borrow checker never saw. This lesson opens one and finds the bottom: a few operations the compiler performs without checking them. Rust names those operations and allows them only behind the keyword unsafe, and each arrives with an obligation that a human, not the compiler, must meet. What that buys is a standard every unsafe block can be judged by: soundness — no safe caller can cause undefined behavior.
unsafe is not a switch that turns Rust into C. It is the place where the proof changes hands: the compiler stops checking a short, named list of operations, and the programmer takes over their obligations — states them (# Safety), claims to meet them (// SAFETY:), and answers for every safe caller who could break them. Lesson 00's promise holds exactly as far as those claims are true.New idea:
unsafe hands the proof for those operations to a human: it grants a few extra powers, states the obligations that come with them, and switches nothing else off. Soundness — no safe caller can cause undefined behavior — is the standard.Forces next: The keyword unsafe hands the proof to a human: the obligations are stated, but stating them is not discharging them. How do you build a safe abstraction on an unsafe core so that no safe caller, however hostile, can break it?
Vec's and derive the one escape hatch the budget allows. (2) Separate declaring an obligation from discharging it. (3) Show what unsafe does not switch off. (4) Write the obligations down: what stays undefined. (5) Run them on a tiny abstract machine. (6) Meet the obligation nobody can see: a foreign function. (7) Define soundness, and the microscope that hunts for its failures.1 · What is inside a Vec?
Lesson 00 called v.push(4) and watched the checker refuse a stale reference; it never looked inside push. Here is a push with no Vec around it: three u32 slots from the allocator, written in turn.
use std::alloc::{alloc, dealloc, handle_alloc_error, Layout};
fn main() {
let layout = Layout::array::<u32>(3).unwrap(); // room for three u32
let buf = unsafe { alloc(layout) } as *mut u32; // uninitialized bytes
if buf.is_null() { handle_alloc_error(layout) }
let mut len = 0;
for x in [10, 20, 30] {
unsafe { buf.add(len).write(x) }; // who checked len < 3?
len += 1;
}
println!("{}", unsafe { buf.add(2).read() });
unsafe { dealloc(buf as *mut u8, layout) };
}
30
The program is correct, and the compiler verified none of what makes it correct. That len < 3 at every write, that the address suits a u32, that read looks at a written slot, that dealloc gets the same layout back — none of it is a type, a borrow or a lifetime. Iterate over four values and it still compiles; the fourth write lands past the block (§5 runs exactly that). The Nomicon, the Rust project's book on unsafe code, builds a Vec from scratch in this style, and the Book describes parts of the standard library as exactly this: safe abstractions over audited unsafe code. So the language needs a way for code to say "the proof for this part is mine". Three designs were available:
| Road | What it costs |
|---|---|
| No escape hatch: every operation checked | No Vec, no Mutex, no threads, no call into the operating system (the Book calls talking to the OS inherently unsafe): no standard library in Rust. |
| Raw pointers as ordinary values, usable anywhere (C++) | Any line may break an invariant, so no line can be trusted without reading all of them: the promise is a hope again. |
A short list of operations the compiler performs unchecked, allowed only inside code marked unsafe | A few visible, greppable places carry the proof; everything else keeps every check. |
Rust took the third road. The Book lists five unsafe superpowers, all used here:
static mut HITS: u32 = 0;
unsafe trait Zeroable {} // an unsafe trait: implementers must promise
unsafe impl Zeroable for u32 {} // (4) implement an unsafe trait
union Bits { n: u32, f: f32 }
unsafe fn get_fast(v: &[u32], i: usize) -> u32 { unsafe { *v.get_unchecked(i) } }
fn main() {
let x = 7u32;
let p = &raw const x; // creating a raw pointer: safe
let bits = Bits { f: 1.0 }; // writing a union field: safe
unsafe {
let a = *p; // (1) dereference a raw pointer
let b = get_fast(&[a], 0); // (2) call an unsafe function
HITS += b; // (3) modify a mutable static
let c = bits.n; // (5) read a union field
println!("{a} {b} {c:#x}");
}
}
7 7 0x3f800000
A raw pointer (*const T, *mut T) is an address the borrow checker does not track: it may alias, dangle or be null, and making one is safe; only using one is not. The Reference's list is longer — eight operations as of 2026-09 — because it also counts calling a safe #[target_feature] function from code without those CPU features, declaring an extern block, and applying an unsafe attribute such as #[unsafe(no_mangle)]. That is the whole grant, and it raises the next question at once: when get_fast skips its bounds check, who owes the proof that i is in range?
2 · Declaring an obligation, discharging it
Lesson 02 left a debt: std::str::from_utf8_unchecked makes a &str without checking that the bytes are UTF-8, and every later reader of that str trusts that they are. Call it like any function:
fn main() {
let bytes = vec![b'o', b'k'];
let s = std::str::from_utf8_unchecked(&bytes); // who checks the UTF-8?
println!("{s}");
}
error[E0133]: call to unsafe function `from_utf8_unchecked` is unsafe and requires unsafe block
--> src/main.rs:3:13
|
3 | let s = std::str::from_utf8_unchecked(&bytes); // who checks the UTF-8?
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
The message names the power you used and where. Its note is the honest part: the obligation lives in the documentation, not in the type, so the compiler cannot tell you whether you can meet it. Wrapping the call in unsafe { } silences the error whether or not the bytes are UTF-8 — the block is a claim, not a check. Treat E0133 as the alarm that a proof is now yours, never as a prompt to type unsafe.fn main() {
let bytes = vec![b'o', b'k'];
// SAFETY: every byte is ASCII, and ASCII is valid UTF-8.
let s = unsafe { std::str::from_utf8_unchecked(&bytes) };
println!("{s}");
}
ok
The Reference gives unsafe exactly two jobs. It declares an obligation that someone else must meet — unsafe fn (every caller must uphold a stated condition), unsafe trait (every implementer must) — or it discharges one, asserting that the conditions hold: an unsafe { } block, an unsafe impl. The convention writes both halves down: a # Safety section where the obligation is declared, a // SAFETY: comment where it is discharged. A declaration, and a safe function that discharges it, written the way edition 2024 no longer accepts in silence:
/// Returns `v[i]` without a bounds check.
///
/// # Safety
/// `i` must be less than `v.len()`.
unsafe fn get_fast(v: &[u32], i: usize) -> u32 {
*v.get_unchecked(i) // an unsafe call with no block of its own
}
fn first_plus_second(v: &[u32]) -> u32 { // a SAFE function
assert!(v.len() >= 2); // the check that discharges it
// SAFETY: the assert proved 0 and 1 are in bounds.
unsafe { get_fast(v, 0) + get_fast(v, 1) }
}
fn main() { println!("{}", first_plus_second(&[10, 20, 30])); }
30
warning[E0133]: call to unsafe function `core::slice::<impl [T]>::get_unchecked` is unsafe and requires unsafe block
note: an unsafe function restricts its caller, but its body is safe by default
The note is the distinction in one line. Before edition 2024 the body of an unsafe fn counted as one big unsafe block, so declaring an obligation quietly discharged every obligation inside it; edition 2024's lint unsafe_op_in_unsafe_fn warns until each unsafe operation in the body has its own block and justification, as get_fast in §1 has. Meanwhile first_plus_second contains unsafe and is still safe to call: its callers owe nothing, because the assert! turned the obligation into a check the function performs itself. Lesson 16's unsafe impl Send was a discharge too, of the obligation Send declares. With the two roles apart, the next question is what the keyword leaves in force.
3 · What unsafe does not switch off
A common reading of unsafe is "for when the borrow checker is wrong", as though the block turned the checker off. Test it on Lesson 00's program:
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
unsafe {
v.push(4); // does `unsafe` switch the law off here?
}
println!("{first}");
}
warning: unnecessary `unsafe` block
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
The same E0502, and a second message: the block is unnecessary, because push is none of the five powers. unsafe changes which operations the compiler accepts; it changes no rule about references. What changes the outcome is a name the checker never tracked. Swap the reference for a raw pointer:
fn main() {
let mut v = vec![1, 2, 3];
let first = v.as_ptr(); // a raw pointer: not a loan
v.push(4); // accepted: nothing tracks `first`
println!("{}", unsafe { *first }); // compiles. Never run it.
}
It compiles. The push may move the buffer, and then *first reads freed memory: Lesson 00's use-after-free, back. The checker did not fail; it was never told. A raw pointer is a name outside the law; each dereference promises that the law's work was done by hand. So we need the exact list of what that promise covers.
4 · The obligations: what stays undefined
Every power carries the same fine print: do not cause undefined behavior. The Reference keeps the list, says plainly that it is incomplete and may change, and adds that unsafe permits none of it — it only moves the burden of avoiding UB to the programmer. The Reference's items, grouped by what goes wrong, with the mechanism that discharged each obligation for us until now:
| Family (the Reference's item) | Who discharged it in safe Rust |
|---|---|
| Dangling access: a load or store through a pointer whose bytes are not all inside one live allocation (freed, out of scope, out of bounds, or null) | ownership, borrows, lifetimes (03–08); a slice's length check (02); Option instead of null (09) |
| Misaligned access: a load or store through a pointer not aligned for its type | references are always aligned |
Invalid values: a bool other than 0 or 1; a char above 0x10FFFF or a surrogate; a null, dangling or misaligned reference or Box; an enum with an invalid discriminant | the type system: no safe operation makes one |
| Uninitialized reads: an integer, float or raw pointer must not come from uninitialized memory | definite assignment (02) |
| Data races | Send and Sync (16) |
Aliasing: while a &T is live its target does not change (bytes inside an UnsafeCell excepted); while a &mut T is live nothing else touches its target | the borrow checker (05–07) |
| The rest: a field or index offset out of bounds, writing immutable bytes, a call through the wrong ABI, code built for CPU features the machine lacks, misused intrinsics or inline assembly, breaking the runtime's assumptions | unreachable from safe code |
A str that is not UTF-8 is not on the list: the Reference treats a str like a [u8]. Its UTF-8 promise is the library's (Lesson 02), which is why from_utf8_unchecked states it as that function's own condition. Three of the rows in five lines, with tools §6 names: the compiler accepts every line, and each of the last four alone makes the whole program undefined:
fn main() {
let dangling: *const u32 = { let x = 7u32; &raw const x }; // x's scope ends here
let _flag: bool = unsafe { std::mem::transmute(2u8) }; // a bool must be 0 or 1
let _c = unsafe { char::from_u32_unchecked(0xD800) }; // a surrogate is no char
let _n: u32 = unsafe { std::mem::MaybeUninit::uninit().assume_init() }; // never written
let _v = unsafe { *dangling }; // reads a dead local
}
warning: the type `u32` does not permit being left uninitialized
= note: integers must be initialized
One line draws a warning: a lint recognizes uninit().assume_init() at an integer type. The other UB lines pass in silence. A lint matches shapes; it does not discharge obligations. The safe counterparts show what each missing check was:
use std::mem::MaybeUninit;
fn main() {
let x = 7u32;
let r = &x; // a reference: the checker keeps x alive
let flag = 2u8 != 0; // decide what the byte means
let c = char::from_u32(0xD800); // checked: None for a surrogate
let mut slot = MaybeUninit::<u32>::uninit();
slot.write(5); // written first...
let n = unsafe { slot.assume_init() }; // ...so assume_init's condition holds
println!("{r} {flag} {c:?} {n}");
}
7 true None 5
The aliasing row needs a warning of its own: the exact rules are not settled. The Reference gives only the outline in the table; Miri's Stacked Borrows and Tree Borrows are experimental models, not the definition. Even the outline is not academic: Lesson 01 measured add_twice(a: &mut i32, b: &mut i32) at full optimization: it loads *a and *b first, stores b + 1, then stores a + 2. Hand it two &mut to one place and the compiler raises no objection:
fn add_twice(a: &mut i32, b: &mut i32) {
*a += 1;
*b += 1;
*a += 1;
}
fn main() {
let mut x = 0;
let p = &raw mut x;
unsafe { add_twice(&mut *p, &mut *p) }; // two &mut to one place. Never run it.
println!("{x}");
}
On one place holding 0, those measured instructions load 0 twice, store 1, then store 2: three increments leave 2. UB is not a wrong answer computed from your source; it is the optimizer's assumption computing something else — the C++ track's Lesson 19 watched one delete a null check. The data-race row has a door of its own, static mut: two threads each running unsafe { N += 1 } compile, and race. Even on one thread, a reference to a static mut is an aliasing promise only a whole-program argument could keep, so in edition 2024 the lint static_mut_refs refuses &HITS by default (an error with no code). Copies and raw pointers remain:
static mut HITS: u32 = 0;
fn main() {
unsafe { HITS += 1; } // a read-modify-write: no reference formed
let n = unsafe { HITS }; // a copy out
let p = &raw const HITS; // a raw pointer: safe to create
println!("{n} {}", unsafe { *p });
}
1 1
Hash that disagrees with Eq) as unwanted but not undefined. The flip side binds unsafe code: since a destructor may never run (Lesson 03), no soundness argument may depend on one.The table speaks about an abstract machine — allocations, their bytes, pointers into them — not a real computer. The quickest way to learn to read it is to run one.
5 · Run the obligations
The widget is a small abstract machine for the first four rows. It keeps what those rules talk about — each allocation's size, alignment, liveness and written bytes, each raw pointer's allocation, offset and element type — and checks every step's obligations before running it. At the first failure it names the rule and the check that would have discharged it. It judges alignment against every address the allocator could have returned, and models neither aliasing (nobody can yet say exactly what to model) nor threads. Its oracle: 73 hand-derived cases with verdicts fixed in advance, 600 random scripts against a separately written byte-level model, and all 260 scripts it calls defined compiled and run as real Rust: no disagreement.
What to try. The default script is §1's buffer with a fourth push. Drag the slider from 0 to 10: steps 1–8 run, including step 8's add(3), which lands exactly on the block's end — allowed, if nothing is accessed there. Step 9 stops the machine (bytes 12..16 of a 12-byte block), and step 10, the dealloc, never happens. Edit alloc(12, 4) to alloc(16, 4) and run it: defined. "add past the end" stops at step 4 with nothing read, because add itself promises to stay inside its block. "alignment only hoped for" stops at step 3 with a weaker sentence — misaligned on some allocations — since 1-byte alignment was all the script asked for, and a proof must cover every allocation. "a leak (not UB)" ends defined, with block A leaked. Every verdict came from facts the script states: a size, an offset, an alignment. A foreign function states none of them.
6 · The obligation nobody can see: foreign code
The machine's obligations are at least visible in the script; a foreign function's are not. Declaring one gives the compiler a name, a signature and an ABI that it has no way to check against the library. Edition 2024 makes the declaration say so:
extern "C" {
fn abs(x: i32) -> i32;
}
fn main() { println!("{}", unsafe { abs(-3) }); }
error: extern blocks must be unsafe
Inside an unsafe extern block each item then says who owes what: safe fn is a promise we make about the foreign code, while an unqualified or unsafe fn leaves the obligation with every caller:
use std::ffi::c_char;
unsafe extern "C" {
safe fn sqrt(x: f64) -> f64; // our claim: fine for every f64
unsafe fn strlen(s: *const c_char) -> usize; // caller must pass a NUL-terminated string
}
fn main() {
println!("{}", sqrt(2.0)); // no unsafe block: we declared it safe
let name = c"ferris"; // a C string literal
// SAFETY: `name` is NUL-terminated and lives until after the call.
let n = unsafe { strlen(name.as_ptr()) };
println!("{n}");
}
1.4142135623730951 6
Declaring sqrt safe (the Edition Guide's own example) is our claim, not the compiler's. strlen needs bytes ending in a NUL; c"ferris" supplies them, so the SAFETY comment can say why the obligation holds. If the declaration is wrong, every call is wrong: a call through the wrong ABI is on the Reference's list, and UB inside the C function is UB of the whole program, Rust included.
Inside Rust the same exchange is made by four library tools, each changing one guarantee and handing you the obligation that comes with it:
| Tool | What it changes | The obligation it hands you |
|---|---|---|
MaybeUninit<T> | "initialized": any bytes are a valid MaybeUninit<T> | call assume_init only once every byte is written; an uninitialized integer is UB even unused |
ManuallyDrop<T> | the automatic drop | drop it yourself, at most once; it relaxes no validity rule |
NonNull<T> | it adds "never null" to a *mut T | stay non-null even if never dereferenced; it may still dangle |
mem::transmute | the type: the same bytes, read as another | the bytes must be a valid value of the new type; only the sizes are checked |
fn main() {
let x: u64 = unsafe { std::mem::transmute::<u32, u64>(1) }; // 4 bytes as 8?
println!("{x}");
}
The size check is the part a compiler can do; §4's transmute(2u8) to bool passed it — same size, invalid value. Each tool moves one check from the compiler to us; what is left is knowing whether we did it for every caller.
7 · Soundness, and the microscope
Every block in this lesson faced one question: could any caller break it? The question has a name. Unsafe code that no safe client can use to cause undefined behavior is sound; unsafe code that some safe client can misuse into UB is unsound, and the Reference puts the fault with the author of the unsafe code, not the client. A readings buffer that skipped its check:
/// Returns reading `i`.
pub fn nth(readings: &[u32], i: usize) -> u32 {
unsafe { *readings.get_unchecked(i) } // i < len? the caller decides
}
Perhaps every caller in the codebase passes a valid index; it is unsound anyway, because soundness is a property of the API over every caller, not of one run. The audit is the drill this series has run since Lesson 01 — if the compiler accepted this, write the program that misbehaves — now aimed at our own code: nth(&[7, 8], 5) is a safe call that reads past the slice. The repair turns the obligation into a check the function performs:
pub fn nth(readings: &[u32], i: usize) -> Option<u32> {
if i < readings.len() {
// SAFETY: i < readings.len(), checked on the line above.
Some(unsafe { *readings.get_unchecked(i) })
} else {
None
}
}
fn main() { println!("{:?} {:?}", nth(&[7, 8], 1), nth(&[7, 8], 5)); }
Some(8) None
Notice where the proof now lives: in i < readings.len(), a line of safe code. The Nomicon makes the point with the same shape: change that < to <=, a purely safe edit, and the function is unsound again; in its toy Vec, a safe method that bumps cap makes the unsafe push unsound without touching it. An unsafe block's argument depends on state that safe code can change, so the Nomicon concludes that the unit of audit is the module, and privacy its only reliable boundary.
Tests cannot close that gap, but they can hunt for failures. Miri, an official Rust tool, runs a program or its tests in an interpreter that checks each executed step for out-of-bounds accesses and use-after-free, uninitialized data, misalignment, invalid values such as a bool that is not 0 or 1, data races and — marked experimental — aliasing under Stacked or Tree Borrows. Like the C++ track's sanitizers, it has honest limits: it sees only the paths a run takes, it approximates UB because no official specification exists, and most foreign calls cannot run inside it. If Miri reports UB, the code is unsound; if it reports none, the code may still be. As of 2026-09 it needs a nightly toolchain (rustup +nightly component add miri, then cargo +nightly miri test); this page was checked on stable rustc 1.98.1, without it. Stating an obligation is not discharging it, and Miri can only look for the caller who breaks it.
Common mistakes / failure modes
unsafe switches the checker off; it is for when the checker is wrong"push inside unsafe is still E0502 (§3); the keyword adds §1's powers and nothing else. In a study of the Book's quizzes, a question on which uses are legitimate was answered fully correctly by only 6.9% (chance: 6.25%). Those are what the checker cannot see at all: a hand-kept length, foreign code, the OS (§1).unsafe code is bad code"unsafe?" but "is it sound?" (§7).nth works for every current caller (§7); "alignment only hoped for" works whenever the allocator happened to be generous (§5).unsafe inside a function makes it unsafe to call"first_plus_second contains a block and is safe to call: the block discharges the obligation. Only unsafe fn passes it to callers (§2).add_twice (§4), and after UB the machine has no next step at all (§5).static mut is just a global"Mutex or a OnceLock (Lesson 17), or raw pointers (§4).Checkpoint exercise
bytes.add(1) to bytes.add(4). Before pressing run, predict the step and the rule; the widget is the answer key. Then add the one line that makes the script defined, and predict what it prints and what leaks. Finally, spell §7's hostile call nth(&[7, 8], 5) as a script — a 4-aligned block of two u32s, both written, then add(5) on the *mut u32 and a read — and predict which step stops it, and under which rule.Where this points next
We found the bottom of the library types: a handful of operations the compiler performs unchecked, each with an obligation a human states and claims to meet, all judged by one standard — no safe caller may cause undefined behavior. nth's proof was one comparison in safe code, and the Nomicon's Vec keeps its proof in a len and a cap that any method of the type could corrupt. A // SAFETY: comment states a proof; it does not make the proof hold for every caller. Lesson 20 moves from one block to a whole type: how do you wrap an unsafe core in an API that every safe caller, however hostile, can only use soundly?
unsafe fn, touch a static mut, implement an unsafe trait, read a union field; the Reference lists eight). The keyword has two jobs: unsafe fn and unsafe trait declare an obligation (# Safety), unsafe {} and unsafe impl discharge one (// SAFETY:), and edition 2024 keeps them apart. It switches nothing off: the borrow checker still runs; raw pointers are names it never tracked. The obligations are the Reference's UB list — dangling, misaligned, invalid or uninitialized values, data races, aliasing (unsettled); leaks, deadlocks and overflow are not on it. The standard is soundness: no safe caller can cause UB. Miri hunts for counterexamples on the paths a test runs; it cannot prove there are none.Interview prompts
- What can
unsafecode do that safe code cannot? (§1 — the Book's five: dereference a raw pointer, call an unsafe function, touch astatic mut, implement an unsafe trait, read a union field; the Reference lists eight. Nothing else changes.) - Does
unsafeturn off the borrow checker? (§3 — no: a conflictingpushinside an unsafe block is stillE0502, and the block is flagged as unnecessary. What escapes the checker is a raw pointer, which it never tracked.) - What is the difference between an
unsafe fnand anunsafeblock? (§2 — the function declares an obligation for its callers (# Safety); the block discharges one (// SAFETY:). A safe function containing a block stays safe to call; since edition 2024 an unsafe fn's body needs blocks of its own.) - When is
unsafelegitimate? (§1, §3 — when the proof involves what the checker cannot express: a hand-kept length, foreign code, the OS. Never to silence a borrow error, which the block does not do.) - What must unsafe code avoid, and why is UB worse than a wrong answer? (§4 — dangling or null access, misalignment, invalid or uninitialized values, data races, aliasing violations; the list is incomplete. The optimizer assumes none happens:
add_twice's measured code leaves 2 where three increments say 3.) - What makes an unsafe API sound, and how can one that passes every test be unsound? (§7 — sound means no safe caller can cause UB through it.
nthwithout its check works for every current caller, yet the safe callnth(&[7, 8], 5)reads past the slice.) - What is Miri, and what can it not tell you? (§7 — an interpreter that reports UB on the paths a run executes, aliasing only experimentally. Finding UB proves unsoundness; finding none proves nothing. As of 2026-09 it needs nightly.)
Companion reads: C++ · 19 Undefined behavior (the catalog §4 answers), C++ · 03 Pointers and references (raw pointers), C++ · 02 Objects and the memory model (alignment), Go · 03 Pointers, heap and GC (Go's unsafe package).