AnyLearn
All lessons
Programmingadvanced

Unsafe, and Where the Guarantee Actually Ends

The borrow checker rejects some correct programs, so Rust provides ways out: runtime-checked cells, reference counting, and unsafe blocks. This lesson covers what each escape hatch turns off, what it does not, and the encapsulation discipline that keeps a small unsafe core from voiding the guarantee for everything above it.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

The checker is conservative on purpose

The borrow checker proves safety, so anything it cannot prove it refuses. That makes it sound but incomplete: no unsafe program is accepted, and some safe ones are rejected.

The lesson What Undecidability Costs Real Tools explains why this is not a gap to be closed. Rice's theorem says every non-trivial semantic property is undecidable, so an analyser must choose which way to be wrong. The borrow checker chose to reject rather than to miss.

Which means a language built only on it would be unable to express things that are genuinely fine. A doubly linked list, a cache with shared entries, a call into a C library: all safe in fact, none provable by the rule.

So Rust provides escape hatches, and the interesting part is that they are graded. Each gives up a different amount of checking, and reaching for the strongest one when a weaker suffices is the common error.

The order below is the order to try them in.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. The checker is conservative on purpose

The borrow checker proves safety, so anything it cannot prove it refuses. That makes it sound but incomplete: no unsafe program is accepted, and some safe ones are rejected.

The lesson What Undecidability Costs Real Tools explains why this is not a gap to be closed. Rice's theorem says every non-trivial semantic property is undecidable, so an analyser must choose which way to be wrong. The borrow checker chose to reject rather than to miss.

Which means a language built only on it would be unable to express things that are genuinely fine. A doubly linked list, a cache with shared entries, a call into a C library: all safe in fact, none provable by the rule.

So Rust provides escape hatches, and the interesting part is that they are graded. Each gives up a different amount of checking, and reaching for the strongest one when a weaker suffices is the common error.

The order below is the order to try them in.

2. Move the check to runtime

The first hatch keeps the rule and changes when it is verified.

RefCell<T> enforces aliasing XOR mutability at runtime. Borrowing it returns a guard, a counter tracks outstanding borrows, and violating the rule panics rather than being rejected at compile time.

use std::cell::RefCell;

let c = RefCell::new(vec![1, 2, 3]);
c.borrow_mut().push(4);            // fine: the borrow ends immediately

let a = c.borrow();
let b = c.borrow_mut();            // panics: already immutably borrowed

This is interior mutability: a value that can be mutated through a shared reference, which the type system otherwise forbids. It is what makes shared-ownership data structures expressible.

The trade is explicit. You gain the ability to write patterns the compiler cannot prove, and you accept that a violation is a runtime panic rather than a compile error, plus a small counter check per borrow.

The crucial property is that it is still safe: the rule is enforced, just later and more loudly. A RefCell cannot corrupt memory. It can crash your program, which is a categorically better failure than undefined behaviour.

3. Move ownership to runtime

The second hatch relaxes the single owner rule instead of the borrow rule.

Rc<T> is a reference-counted pointer: cloning it increments a count, dropping decrements, and the value is freed when the count reaches zero. Ownership is shared and the freeing decision has moved to runtime.

Combined with RefCell, this gives shared mutable state in a single thread, which is the standard construction for graph-shaped data:

use std::rc::Rc;
use std::cell::RefCell;

type Shared<T> = Rc<RefCell<T>>;
let node: Shared<Vec<i32>> = Rc::new(RefCell::new(vec![]));
let also = Rc::clone(&node);       // two owners
also.borrow_mut().push(1);

The cost is the failure mode nobody expects: reference cycles leak. Two nodes pointing at each other keep each other's count above zero forever, and neither is ever freed.

That is a genuine leak in a language celebrated for memory safety, and it is deliberate. Leaking is safe in Rust's sense, because it wastes memory without corrupting anything. The remedy is Weak<T>, a non-owning reference that does not contribute to the count, used for back-edges: children hold Rc to their parents' children, parents hold Weak back.

This is exactly the garbage collection trade-off reappearing. Reference counting cannot collect cycles, which is why tracing collectors exist.

4. Turn the checking off

The last hatch is unsafe, and the most common misunderstanding about it is worth correcting immediately.

An unsafe block does not disable the borrow checker. Ownership, borrowing and lifetimes are enforced inside it exactly as outside. What it enables is five specific abilities:

  • Dereference a raw pointer
  • Call an unsafe function, including any foreign function
  • Access or modify a mutable static
  • Implement an unsafe trait, such as asserting Send or Sync manually
  • Access a union's fields

That is the entire list. unsafe is not a mode where anything goes; it is permission to use five constructs whose correctness the compiler cannot check.

let mut x = 5;
let p = &mut x as *mut i32;
unsafe { *p += 1; }              // dereferencing a raw pointer

The keyword's real function is auditability. Every place in a codebase where the compiler's guarantee is suspended is marked with a greppable token, so a security review has a finite list to examine rather than an entire program.

That is the design insight: the guarantee is not that unsafety is absent, but that it is located.

5. The escape hatches, in order

Work down the list, not up. Each step gives up more checking than the one above, and the first step handles most cases.

Restructuring is usually the answer and is usually better code. Replacing a reference with an index, narrowing a borrow's scope, or cloning a small value removes the conflict and frequently improves the design, because the borrow the checker objected to was often a design smell.

The last box is the one that makes the whole system work. Reaching unsafe is not the end: the discipline is to wrap it in a safe abstraction whose API cannot be misused, so callers never write unsafe themselves.

That is how the standard library is built. Vec uses raw pointers and manual allocation internally, and exposes an interface where every operation is safe. The unsafe code is a small, audited, heavily tested core; everything above it inherits the guarantee.

That encapsulation is the load-bearing idea of the whole language, and it is what the next step formalises.

flowchart TD
A["Borrow checker rejects it"] --> B["Restructure: use an index or clone"]
B --> C["Still stuck: Cell or RefCell"]
C --> D["Need shared ownership: Rc or Arc"]
D --> E["Need cycles: Weak for back-edges"]
E --> F["Genuinely beyond the model: unsafe"]
F --> G["Encapsulate it behind a safe API"]

6. The encapsulation contract

The claim that a safe wrapper around unsafe code preserves the guarantee needs stating precisely, because it is what everything rests on.

The obligation is: no combination of calls to the safe API, from safe code, may cause undefined behaviour. Not the calls the author anticipated. Any combination, including malicious ones.

That is a strong requirement and it is where soundness bugs come from. A wrapper that is safe when used as intended, and unsound when a caller does something unexpected, has voided the guarantee for every user, and no unsafe keyword appears at the call site to warn them.

This is why the previous lesson's citation matters here. The RustBelt work is built exactly around this contract: it states, for a library using unsafe features, the verification conditions it must satisfy to count as a safe extension of the language. That reframes an informal discipline into a checkable obligation, and it is what makes the extensibility of the proof possible.

The practical reading for anyone writing such a wrapper: the question is not whether your unsafe code is correct for the calls you had in mind. It is whether any safe program at all can reach undefined behaviour through your API.

7. Where unsafe legitimately appears

Four places account for nearly all of it, and none is a failure of the language.

Foreign function interfaces. Calling C is inherently unsafe: the compiler has no visibility into the callee, and the C side is not bound by any of these rules. Any binding to an existing library has an unsafe boundary by construction.

Fundamental data structures. Vec, HashMap, Arc and linked structures need raw pointers because their internal invariants are exactly the ones the checker cannot express. Someone has to write them, and it is better that it be a small number of heavily reviewed implementations.

Hardware access. Memory-mapped registers, interrupt handlers and inline assembly are not describable in the safe subset because their effects are outside the language's model.

Verified performance work. Eliding a bounds check in a hot loop where the index is provably in range. This is the one to be most sceptical of: the check is usually cheap and the compiler often removes it anyway, so measure first.

The healthy pattern across all four is concentration. A well-built system has unsafe code in a few small modules with high review attention, and none in application logic, which is the exact opposite of C where every line has the same exposure.

8. What the path established

ToolRelaxesCheckedFailure mode
ReferencesNothingCompile timeCompile error
Cell, RefCellWhen the borrow rule is checkedRuntimePanic
Rc, ArcSingle ownershipRuntimeLeak on cycles
WeakNothing; breaks cyclesRuntimeUpgrade returns None
Mutex, RwLockWhen exclusivity is obtainedRuntimeBlocking, deadlock
unsafeFive specific abilitiesNot at allUndefined behaviour

Read the last column downwards and the design becomes clear. The failure modes degrade in a deliberate order: compile error, then panic, then leak, then deadlock, then undefined behaviour. You choose how far down that list to go, and you choose it explicitly, in code that says which tool you picked.

That is the summary of the whole cursus. Ownership decides freeing at compile time. Borrowing forbids aliasing plus mutation, which eliminates dangling references, iterator invalidation and data races in one rule. And the escape hatches let you step outside when the rule is wrong for your case, in graded increments, with the unsafety located rather than eliminated.

The transferable idea, useful well beyond Rust: a guarantee that holds everywhere except in a small, marked, audited region is worth far more than one that holds nowhere, and it is achievable when the language makes the region visible.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What does an `unsafe` block actually enable?
    • Five specific abilities, such as dereferencing raw pointers and calling foreign functions
    • Suspension of the borrow checker within the block
    • Direct control over when values are dropped
    • Bypassing lifetime annotations on references
  2. How does `RefCell` differ from ordinary borrowing?
    • It permits aliasing and mutation simultaneously
    • It removes the borrow rule for the wrapped value
    • It enforces the same rule at runtime, panicking on violation instead of failing to compile
    • It moves the value to the heap so borrows cannot dangle
  3. Why can `Rc` leak memory?
    • Because dropping an Rc does not decrement the count
    • Because reference cycles keep each other's counts above zero forever
    • Because it allocates without a matching free
    • Because clones are never deallocated
  4. What is the obligation on a safe API wrapping unsafe code?
    • That the unsafe block is as small as possible
    • That callers are documented as needing care
    • That the unsafe code is correct for the calls the author anticipated
    • That no combination of calls from safe code, including malicious ones, can cause undefined behaviour
  5. What is the intended ordering of Rust's escape hatches?
    • Restructure first, then runtime-checked cells, then shared ownership, and unsafe last
    • Unsafe first for performance, then add checks where problems appear
    • Rc everywhere, falling back to references when cycles are impossible
    • Whichever compiles fastest, since all are equally safe

Related lessons

Programming
intermediate

Benchmarks That Hold Up, and Knowing When to Stop

A benchmark is an experiment, and most are badly designed enough to produce confident wrong answers. This lesson covers what a measurement must control to mean anything, the ways microbenchmarks lie including code the compiler deletes, how to catch regressions in continuous integration despite noisy machines, and how to recognise the point where optimising stops paying.

7 steps·~11 min
Programming
intermediate

Measure First: The Arithmetic That Decides What to Optimise

Most optimisation effort is spent on code that was never the problem, and the reason is that intuition about where time goes is reliably wrong. This lesson covers why guessing fails, the arithmetic that caps what any optimisation can buy, the difference between latency and throughput, and how to set a target that tells you when to stop.

7 steps·~11 min
Programming
intermediate

Making Rollback Possible: The Changes That Cannot Be Undone

Every deployment strategy assumes you can go back, and that assumption is the one most often false when it matters. This lesson covers what actually makes a rollback work, the database migration pattern that keeps schema changes reversible, the one-way doors that no amount of tooling can undo, and how to tell which kind of change you are about to ship.

7 steps·~11 min
Programming
intermediate

Feature Flags: Separating Deploy From Release

A feature flag turns shipping code and exposing behaviour into two independent decisions, which is what lets a team deploy continuously while releasing on someone else's schedule. This lesson covers the four kinds of flag and their very different lifespans, the debt they accumulate, and the discipline that keeps a flag system from becoming untestable.

7 steps·~11 min