You have spent sixteen tracks learning a language in which the compiler proves your program cannot corrupt memory. This track is about the other language.
Rust is two languages sharing one syntax. In Safe Rust the compiler carries the proof burden: aliasing, lifetimes, initialisation, all checked, all mechanical. In Unsafe Rust the burden moves to you — and the essential point, the one that separates people who write unsafe Rust from people who write C in Rust syntax, is that you must actually discharge it, and write the proof down.
This page has one job: give you three words with exact meanings. Every later item in this track is an application of them.
Undefined behaviour
Undefined behaviour is not a crash. It is not a wrong answer. It is the absence of meaning.
Rust’s semantics are defined by an abstract machine. That machine has rules — references are aligned and non-null, &mut is unique, a bool is 0 or 1, a pointer offset stays in its allocation. The compiler is entitled to assume those rules hold, everywhere, unconditionally, and to optimise on that assumption.
If your program breaks one of them, the abstract machine has no defined behaviour for it. Not “an unspecified but reasonable behaviour” — none. Your program has no meaning, and the compiled artefact is under no obligation to resemble what you wrote.
In practice that manifests as:
- the code works for two years, then breaks when LLVM gains an optimisation pass;
- the code works in debug and fails in release, or vice versa;
- the code works until an unrelated function is edited, because inlining changed;
- the bug appears a thousand lines from its cause, in code you did not touch.
💡A colleague says "I know that read is one past the end, but it works fine — I tested it." What is wrong with that argument, precisely? click to reveal
Testing establishes that one build, on one machine, on one input, produced the output you wanted. Undefined behaviour is a claim about the program’s meaning, and a program with no meaning can produce the output you wanted by coincidence.
Concretely: the optimiser is allowed to reason backwards from UB. If it can prove that a branch leads to undefined behaviour, it may conclude that branch is never taken and delete the condition that guards it. That is not a hypothetical — it is the standard mechanism by which a bounds check disappears and a null check disappears and a loop that “obviously” terminates becomes infinite.
So “it works” is evidence about codegen, not about correctness. The only argument that means anything is the one from the rules: this read is inside the allocation, here is why.
Unsound
An API is unsound if some safe caller — any safe caller, however perverse — can use it to cause undefined behaviour.
Read the quantifier carefully. It is exists, not forall. It does not matter whether such a caller exists in your codebase. It does not matter whether the input that triggers it is realistic. If a program consisting only of safe Rust can reach UB through your API, your API is buggy, today, whether or not anyone has noticed.
This is a much stronger standard than “the current callers are fine”, and it is the standard the Rust ecosystem actually holds itself to. It is why a soundness hole in a published crate gets a security advisory even when no known code triggers it.
Sound
An API is sound if no safe caller can cause undefined behaviour, no matter what they do.
That is the goal for everything you build in this track. MyVec::push is sound because every input either works or panics. split_at_mut is sound because its assert! converts every bad mid into a panic. MyRc::clone is sound only if it handles the counter overflow that safe code can drive it into.
Notice what soundness is not: it is not “no unsafe blocks”. It is not “carefully written”. It is a property of the interface — a promise that the set of reachable states, from safe code, contains no undefined ones.
💡fn get(&self, i: usize) -> &T { unsafe { &*self.ptr.add(i) } } — no bounds check, but the struct is private and every caller in your crate passes a valid index. Sound or unsound?
click to reveal
Unsound, if get is pub.
The question is not whether current callers are correct. It is whether a safe caller could pass i = 9999. They could, with no unsafe anywhere, and the result is a read outside the allocation. The API promises “give me any usize“ and cannot honour it.
There are exactly three ways to fix it, and they are the three moves this whole track teaches:
-
Check —
if i >= self.len { return None }, and change the return type toOption<&T>. The precondition becomes a runtime test. -
Panic —
assert!(i < self.len). Still safe, because a panic is defined behaviour. -
Declare — make it
pub unsafe fn get_uncheckedwith a# Safetysection. The obligation moves to the caller, explicitly, in the type.
If get is private and the module never calls it with a bad index, it is sound — but only because privacy bounds who can call it, and only as long as nobody in that module makes a mistake. Item 17.20 is about exactly that, and about why the audit unit is the whole module rather than the block.
What Rust does not consider unsafe
This list surprises people, and getting it wrong wastes enormous effort chasing the wrong guarantees. The Nomicon is explicit: all of the following are safe in Rust’s technical sense.
| Deadlocks | Two threads waiting on each other forever. Safe. |
| Race conditions | Two threads producing an order-dependent result. Safe. |
| Memory leaks |
mem::forget, Box::leak, Rc cycles. Safe, and item 17.21 shows why this is deliberate. |
| Integer overflow | Wraps in release, panics in debug. Both defined. |
Calling abort |
Defined, orderly, immediate. |
| Deleting the user’s files | Rust has opinions about memory, not about your judgement. |
Rust is permissive about logical correctness and strict only about memory safety. It will not stop you writing a program that does the wrong thing; it will stop you writing a program that has no meaning.
Data race versus race condition
These get conflated constantly and they are not the same.
A data race is two threads accessing the same memory, at least one of them writing, with no synchronisation and no happens-before edge. It is undefined behaviour, and Rust’s type system prevents it: that is what Send, Sync and the borrow checker are for.
A race condition is a program whose result depends on timing. It is safe — perfectly defined, just usually a bug.
let counter = Arc::new(Mutex::new(0));
// Two threads each do: let mut g = counter.lock().unwrap(); *g += 1;
No data race — the mutex provides synchronisation. But if your program reads the counter between the two increments and acts on the result, that is a race condition, and Rust will not say a word. Correct, meaningful, defined, and possibly wrong.
💡Why does Rust guarantee freedom from data races but not from race conditions? click to reveal
Because data races are a memory property, decidable from the type system, and race conditions are a logic property, which is not.
A data race is defined in terms of unsynchronised access to a location. Send and Sync encode exactly which types can cross which boundaries, and the borrow checker encodes exactly when two accesses can coexist. Compose those and unsynchronised conflicting access becomes unrepresentable — and the guarantee costs nothing at runtime.
A race condition is about whether your interleaving produces the answer you wanted, and “the answer you wanted” is not something a type system can know. Preventing race conditions in general means proving a program meets a specification, which is exactly the thing Rust deliberately does not attempt.
This is the same line the whole language draws, and it is worth internalising: Rust guarantees your program has a meaning. It does not guarantee the meaning is the one you intended.
Three misconceptions that cause real bugs
“UB is fine if it works on my machine.” Addressed above. The compiler is a party to this argument and it disagrees.
“A data race is the same as a race condition.” One is undefined behaviour, one is a logic bug. Confusing them leads people to reach for unsafe to “fix” a race condition, or to assume the borrow checker will catch a logic error it was never designed to see.
“Unsafe code is where the bugs live.” This is the most important one, and it is backwards.
Unsoundness is usually caused by SAFE code that breaks an invariant the unsafe code trusted. The Nomicon’s example is exact: an unsafe block calling get_unchecked(idx) is correct as long as a safe line above it says if idx < arr.len(). Change that < to <= — in safe code, with no unsafe anywhere near it — and the unchanged unsafe block becomes an out-of-bounds read.
So “I reviewed the unsafe code” is not a soundness argument. The unsafe block did not change; the proof did. Item 17.20 develops this into the practical rule — the audit unit is the module — and explains why privacy is the only thing that keeps the blast radius finite.
The honest limit of this track
Say it once, up front, and it applies to every page that follows:
This harness cannot prove the absence of undefined behaviour.
Miri, the interpreter that detects UB in Rust, needs a nightly toolchain and a cargo project, and cannot run here. A solution with latent UB can pass every test case in this track. What the grader can check is three things: observable behaviour engineered to expose a specific concrete bug; contracts expressed as Result or a panic rather than silence; and discipline, enforced by lints — undocumented_unsafe_blocks, missing_safety_doc, mut_from_ref, not_unsafe_ptr_arg_deref.
Every problem in this track ends by naming the undefined behaviour the harness could not have caught. Read those sections. They are the point.
What to carry into 17.2
Three sentences and one habit.
- Undefined behaviour means the program has no meaning — not that it crashes.
- Unsound means some safe caller can reach UB, whether or not one exists.
- Rust is strict about memory safety and permissive about everything else; leaks, deadlocks and overflow are all safe.
And the habit: whenever you write unsafe, write the sentence that justifies it first. If you cannot write the sentence, you have found the bug.