Part 5 · Unsafe & real code
`unsafe` does not turn off the borrow checker
It unlocks exactly five abilities and disables nothing. The widespread belief that it is an escape hatch from the borrow checker is the fastest way to sound like you have only read about Rust.
What this lesson is trying to break (3)
- `unsafe` disables the borrow checker.
- unsafe code is bad code, and a crate containing it is suspect.
- If it compiles inside `unsafe`, it works.
unsafe is the trapdoor. When the borrow checker refuses something you know
is fine, you wrap it in unsafe and the compiler stops complaining — Rust’s
equivalent of any in TypeScript or @SuppressWarnings in Java.
unsafe disables nothing. The borrow checker runs. The type
checker runs. Lifetimes are checked. Send and Sync are enforced. Wrapping
a borrow error in unsafe changes nothing at all — you get the identical
error.
unsafe adds five abilities that are otherwise forbidden, and
transfers responsibility for a specific set of invariants from the compiler to
you. It is not “turn off checking”; it is “I am now allowed
to do these five things, and I am asserting I have checked what the compiler
cannot”.
The five abilities
Inside an unsafe block, and only there, you may:
- Dereference a raw pointer (
*const T,*mut T) - Call an
unsafefunction or method — including everyextern "C"FFI call - Access or modify a mutable static
- Implement an
unsafetrait (Send,Sync, and others) - Access the fields of a
union
That is the complete list. Nothing about borrows, moves, lifetimes or types changes.
fn main() {
let mut v = vec![1, 2, 3];
unsafe {
let first = &v[0];
v.push(4);
println!("{first}");
}
}No. Exactly the same E0502 you would get without the unsafe block.
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutableThe unsafe did nothing whatsoever. It granted five abilities, none of which
were used, and the borrow checker ran as normal.
If you predicted “yes”, you held the most common misconception about unsafe,
and it is one that a Rust programmer will notice immediately in conversation.
To actually get past the borrow checker here you would have to use raw pointers — opting out of references entirely, not overriding the rules about them:
let ptr: *const i32 = &v[0];
v.push(4);
unsafe { println!("{}", *ptr); } // compiles — and is genuine UBThat compiles and is undefined behaviour: push may reallocate, leaving
ptr dangling. Note the shape of what happened — the compiler did not stop you
because you stopped using the mechanism it checks. That is what unsafe is:
responsibility transfer, not permission override.
Undefined behaviour
The stakes are higher than “might crash”. UB means the compiler is permitted to assume the situation never happens, and to optimise on that basis. A program with UB has no defined meaning at all — it may work for a year and then break when you upgrade the compiler or add an unrelated function.
The main sources in Rust:
- Dereferencing a dangling or unaligned pointer
- Producing two
&mutto the same data (aliasing violation), even transiently - Data races
- Reading uninitialised memory
- Producing an invalid value for a type — a
boolthat is not 0 or 1, an enum with an out-of-range discriminant, a&Tthat is null - Breaking an invariant a safe API relies on (a
Stringcontaining invalid UTF-8)
That fifth one is worth pausing on. unsafe code must uphold every type’s
validity invariant, including the ones the compiler enforces elsewhere. A
String with invalid UTF-8 constructed via from_utf8_unchecked is UB, and the
crash will happen somewhere else entirely.
The point: safe abstractions
Here is the reframing that makes unsafe make sense.
Vec is implemented with raw pointers, manual allocation, and
ptr::copy_nonoverlapping. String too. Rc, Arc, Mutex, HashMap,
slice::split_at_mut — all unsafe inside.
And all of them are impossible to misuse from outside. That is the design:
Confine
unsafebehind an API whose safe surface cannot be used incorrectly.
So “this crate contains unsafe” is not a red flag by itself. Any crate doing
low-level work, FFI, or high-performance data structures will contain some. The
questions worth asking are better ones:
- Is the
unsafeconfined to a small, reviewed module? - Does each block have a
// SAFETY:comment giving the argument? - Does the crate run under Miri in CI?
- Is the safe API genuinely unmisusable, or does it merely document requirements?
// SAFETY: `idx < self.len` was checked on the line above, and `self.ptr`
// is valid for `self.len` elements by the type invariant.
unsafe { &*self.ptr.add(idx) }
That comment is the proof obligation, written down. Its absence in a review is a legitimate and specific thing to raise — far more useful than “why is there unsafe here?”
#![forbid(unsafe_code)]
A crate-level promise that there is no unsafe anywhere in this crate.
forbid (unlike deny) cannot be overridden by an inner allow.
Many web frameworks, parsers and utility crates carry it as a selling point. When
you see it at the top of lib.rs, you can skip an entire category of review
concern — though note it says nothing about the crate’s dependencies.
Edition 2024 changes
Two changes worth knowing, since they show up in newer code and in migration discussions.
The body of an unsafe fn is no longer implicitly an unsafe block. You now
write explicit unsafe {} blocks inside it. The reasoning is good: an
unsafe fn declares “callers must uphold preconditions”, which is a different
statement from “every line in here may do unsafe things”. Conflating them hid
real bugs.
// Edition 2024
unsafe fn thing(p: *const u8) -> u8 {
// SAFETY: caller guarantees p is valid and aligned
unsafe { *p }
}
extern blocks must be marked unsafe extern, and some attributes are now
unsafe(...) — for example #[unsafe(no_mangle)]. Both make the danger visible
at the declaration rather than implicit.
Miri
cargo +nightly miri test runs your tests in an interpreter that detects UB —
out-of-bounds access, use-after-free, alignment violations, aliasing violations
that ordinary testing will never surface.
It is slow, it cannot run FFI, and it does not cover every case. It is still by
some distance the most useful tool for validating unsafe code, and serious
crates run it in CI.
As a tech lead, this is the practical takeaway: if you are evaluating a
dependency that contains meaningful unsafe, checking whether it runs Miri in CI
tells you more than reading the code will.
A team member proposes using unsafe to avoid a .clone() in a hot path.
Write your response. It should: not reject the idea outright, name what would have to be true for it to be acceptable, and specify what you would require before merging.
What you should now be able to say
unsafeunlocks five abilities and disables nothing — the borrow checker still runs.- UB is not “might crash” — it means the program has no defined meaning, and the compiler optimises on the assumption it cannot happen.
- The purpose of
unsafeis to build safe abstractions.VecandStringare unsafe inside and unmisusable outside. // SAFETY:comments are the written proof obligation.- Miri detects UB at run time; CI usage is a good dependency signal.
- Edition 2024 requires explicit
unsafeblocks insideunsafe fn.
Next: 5.2 — The idiom catalogue.