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
advanced

Images: A Filesystem You Can Address by Hash

The kernel gives isolation for files already on the machine. The image format is what turned a directory tree into something you can build once, name by digest and run anywhere. This lesson covers layers and the union filesystem that stacks them, why the build cache behaves as it does, and the content addressing that makes it all work.

8 steps·~12 min
AI
advanced

Building Something That Holds Up

Given that the tradable-signal path is narrow and hard to evidence, the systems worth building are the ones the first lesson identified: extraction at scale. This lesson covers the engineering that makes them survive audit, the evaluation that does not depend on returns, and the governance obligations that apply once a model touches a regulated process.

8 steps·~12 min
Programming
advanced

The Failure Modes the Design Creates

Reconciliation buys robustness and charges for it in a specific currency: nothing is ever definitely finished, commands succeed without meaning anything worked, and manual fixes are silently undone. This lesson covers the failures that come from the model rather than from bugs, how to debug them, and when the whole thing is the wrong tool.

8 steps·~12 min
Programming
advanced

Scheduling and Resources: Requests Are Not Limits

Two numbers govern where a pod lands and how it behaves under pressure, and they do completely different jobs. Requests are used for placement and are a promise; limits are enforced at run time and are a ceiling. This lesson separates them, covers the asymmetry between CPU and memory enforcement, and explains what actually happens when a node runs out.

8 steps·~12 min