all_lessons/Rust/19 · unsafe — taking the proof backlesson 20 / 23

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.

The thesis, here
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.
Linear position
Forced by: Since Rc and RefCell we have leaned on library types — Mutex, Vec, Pin — whose insides the checker cannot verify. What is inside them, what exactly is the compiler taking on trust, and who checks it?
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?
The plan
Seven moves. (1) Open a buffer like 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:

RoadWhat it costs
No escape hatch: every operation checkedNo 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 unsafeA 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}");
}
Reading the error
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 typereferences 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 discriminantthe type system: no safe operation makes one
Uninitialized reads: an integer, float or raw pointer must not come from uninitialized memorydefinite assignment (02)
Data racesSend 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 targetthe 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 assumptionsunreachable 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
What is not undefined
The Reference lists deadlocks, leaks of memory and other resources, exiting without running destructors, integer overflow and logic errors (a 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.

A tiny abstract machine
Rows are blocks and their bytes (·· = never written); triangles are raw pointers. A step runs only if its obligations hold.
step
—
live blocks
—
printed
—
verdict
—
Show the core JS
function alignGuarantee(align, off) { return off === 0 ? align : Math.min(align, lowbit(off)); }

function access(i, p, ty) {   // the obligations of one load or store, in a fixed order
  var t = TY[ty], a = A(p.alloc), where = { alloc: p.alloc, from: p.off, to: p.off + t.size };
  if (a === null) return stop(i, 'null', ..., where);
  if (a.status === 'freed') return stop(i, 'uaf', ..., where);
  if (a.status === 'dead') return stop(i, 'scope', ..., where);
  if (p.off + t.size > a.size) return stop(i, 'oob', ..., where);
  var g = alignGuarantee(a.align, p.off);
  if (g < t.align) {
    var certain = (p.off % Math.min(a.align, t.align)) !== 0;   // misaligned on every allocation?
    where.certain = certain;
    return stop(i, 'align', ..., where);
  }
  return where;   // then a read checks: every byte written; a bool is 0 or 1; a char is a scalar value
}

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:

ToolWhat it changesThe 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 dropdrop it yourself, at most once; it relaxes no validity rule
NonNull<T>it adds "never null" to a *mut Tstay non-null even if never dereferenced; it may still dangle
mem::transmutethe type: the same bytes, read as anotherthe 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"
A conflicting 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"
It is where the proof is hand-made, and the standard library is built on it (§1). Ask not "is there unsafe?" but "is it sound?" (§7).
"If it runs correctly, it is sound"
Soundness covers every safe caller and every allowed allocation. 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).
"UB just gives a wrong answer"
The compiler may assume it never happens: three increments leave 2 in add_twice (§4), and after UB the machine has no next step at all (§5).
"static mut is just a global"
Two threads writing it race, and any reference to it is an aliasing promise only the whole program can keep; edition 2024 refuses such references by default. Use an atomic, a Mutex or a OnceLock (Lesson 17), or raw pointers (§4).

Checkpoint exercise

Try it
In the widget choose "misaligned u32 read" and change 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?

Takeaway
Every safe abstraction bottoms out in operations no compiler can check. Rust names them: the Book's five powers (dereference a raw pointer, call an 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

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).