The Unsafe Rust Contract
Rust's core promise is memory safety without a garbage collector, checked entirely at compile time. unsafe is the keyword that lets a programmer step outside those compile-time guarantees for the handful of operations the compiler cannot verify on its own.
It does not turn off Rust. It unlocks five specific capabilities and leaves every other rule in force, which is the source of most confusion for people new to the language. Understanding exactly what changes under unsafe, and what stays the same, is the foundation for everything else in this section: raw pointers, FFI, the memory model, and the discipline of building safe abstractions on top of unsafe code.
Summary
unsafegrants access to five specific operations the compiler cannot check automatically; it does not disable the rest of Rust's safety net.- Insight: Some real-world work, talking to hardware, calling C libraries, building high-performance data structures, requires operations the borrow checker cannot prove safe, and Rust needs a principled way to allow them without abandoning safety everywhere else.
- Key Concepts: unsafe superpowers, soundness, undefined behavior (UB), safe abstraction, invariant, encapsulation boundary.
- When to Use: Wrapping a C library behind a Rust API, implementing a data structure the borrow checker cannot express, writing performance-critical code that needs raw pointer arithmetic, or working directly with hardware in embedded contexts.
- Limitations/Trade-offs: Every
unsafeblock shifts responsibility for correctness from the compiler to the programmer, and a single missed invariant can produce undefined behavior that manifests far from its actual cause. - Related Topics: raw pointers, the Rust memory model, foreign function interfaces, Miri and sanitizers.
Foundations
unsafe is a keyword, not a mode.
Wrapping code in an unsafe block, marking a function unsafe fn, or writing unsafe trait does not disable borrow checking, move semantics, or the type system inside that scope. Every rule that applies to safe Rust still applies inside unsafe code, with five specific additions layered on top.
Those five additions are what the Rust reference calls the "unsafe superpowers": dereferencing a raw pointer, calling an unsafe fn (including foreign functions), implementing an unsafe trait, accessing or mutating a static mut variable, and reading a field of a union.
Nothing else changes.
You cannot use unsafe to bypass a lifetime, skip a bounds check silently, or make a type implement a trait it does not implement. The type system keeps running exactly as it does in safe code.
A useful analogy is a construction site with a locked tool cage. Most work uses standard tools with built-in safety guards, and the compiler is the inspector checking every use automatically. A few tasks need the power tools in the cage, and getting the key does not turn off the site's other rules; it just means the inspector trusts the operator on that one tool.
The word "unsafe" is also a common source of overreaction.
An unsafe block does not mean "this code is dangerous" in some vague sense. It means "this code performs an operation whose safety the compiler cannot verify, and the programmer is asserting that operation's preconditions hold."
Well-written unsafe code, backed by a documented argument for why its preconditions always hold, can be exactly as reliable as safe code. The entire Rust standard library is built this way: Vec, String, and HashMap all use unsafe internally to get performance the borrow checker alone cannot express, while presenting a fully safe public API.
Mechanics & Interactions
The concept that actually matters for writing correct unsafe code is soundness, and it is easy to misjudge at first.
Soundness is not a property of an unsafe block in isolation. It is a property of the public API surrounding it.
A function is sound if no possible safe caller, using only safe Rust, can trigger undefined behavior by calling it. A function is unsound if there exists even one safe call pattern, however obscure, that leads to UB, even if that pattern never appears in the current codebase.
This distinction reframes the job of writing unsafe code.
The question is never "does this specific call site look safe?" but "have I proven that every possible safe caller, including ones I have not imagined, cannot misuse this function?" That proof usually takes the form of an invariant, some condition that must hold whenever a value of a given type exists, maintained by every safe method the type exposes.
Consider a minimal example: a type that promises its internal buffer is always initialized up to a len field before anything reads it.
pub struct Buffer {
data: Vec<std::mem::MaybeUninit<u8>>,
len: usize, // invariant: data[..len] is always initialized
}
impl Buffer {
pub fn push(&mut self, byte: u8) {
self.data[self.len].write(byte); // unsafe write, invariant upheld
self.len += 1;
}
pub fn get(&self, i: usize) -> Option<u8> {
if i < self.len {
// SAFETY: invariant guarantees data[..len] is initialized
Some(unsafe { self.data[i].assume_init() })
} else {
None
}
}
}Every unsafe operation inside Buffer relies on the same invariant, and every safe method that touches len is responsible for keeping that invariant true. The soundness argument for the whole type collapses to one sentence, checked in one place, rather than re-derived at every call site.
This is also why unsafe code interacts so closely with encapsulation.
Rust's privacy system, pub versus private fields, is not just an API-design nicety when unsafe code is involved. It is the mechanism that makes soundness provable at all.
If len were a public field, any safe caller could set it past the buffer's real length and turn get into a read of uninitialized memory, an instant soundness violation despite the unsafe block itself being written correctly. Encapsulation is what lets a library author say "trust me" once, in a small reviewable surface, instead of asking every downstream user to reason about raw memory.
Undefined behavior itself deserves a mental model here, because it behaves differently from a normal bug.
A logic error in safe Rust produces a wrong answer, a panic, or a crash, something observable and debuggable locally. UB is different: the compiler is allowed to assume it never happens, and once an unsafe precondition is actually violated, the optimizer is free to transform the surrounding code in ways that make the failure appear anywhere, including in code that looks unrelated.
This is why unsafe bugs are notorious for surfacing as a crash in a totally different function, sometimes releases later, and why "it worked when I tested it" carries almost no evidential weight for unsafe code.
Advanced Considerations & Applications
In production Rust codebases, unsafe is usually concentrated into a small number of carefully reviewed modules with a documented safety argument, often a # Safety doc comment above every unsafe fn, rather than sprinkled throughout the code.
Large systems teams frequently track an "unsafe inventory," a periodic audit of every unsafe block, because the cost of an unsound abstraction scales with how much safe code depends on it. Several disciplines compete for managing this risk, and mature codebases typically combine more than one.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Manual review + # Safety comments | Cheap, works everywhere, forces the author to write the argument down | Relies on human diligence; comments can go stale | Every unsafe block, as a baseline |
| Miri (interpreter) | Catches real UB (uninitialized reads, aliasing violations, out-of-bounds) that compiles cleanly | Slow, cannot run all code (no raw syscalls or most FFI) | CI on unsafe-heavy crates |
| Sanitizers (AddressSanitizer, etc.) | Catches memory errors at native speed, works with FFI | Requires nightly toolchain features, platform-specific | Testing FFI-heavy or performance-critical unsafe |
| Fuzzing | Finds edge cases humans do not think to test | Needs a harness and time budget; probabilistic, not proof | Parsers and data structures with complex invariants |
| Typestate / formal encoding | Makes misuse a compile error instead of a documented rule | Adds API complexity; not always expressible | Protocols with a strict valid-usage order |
The distinction between unsafe fn and a pub fn containing an unsafe block is worth calling out, because it is a common design mistake.
Marking a function unsafe fn pushes the safety obligation onto the caller, who must read documentation and manually uphold preconditions the compiler will not check. A safe pub fn that internally uses unsafe keeps that obligation inside the function itself, which is almost always preferable, since only the implementer needs to reason about the invariant instead of every caller forever. Reserve unsafe fn for cases where the precondition genuinely cannot be checked by the function itself, such as "this raw pointer must be valid," where only the caller could know that.
FFI boundaries deserve a special note, because Rust's guarantees stop at the boundary and the foreign side offers none of its own. A common pattern isolates all raw FFI calls in one internal module, then wraps each foreign handle in a safe type before it reaches the rest of the codebase, so the trust boundary is a single audited file.
The Rust project's own trajectory shows this discipline evolving: MaybeUninit replaced the older, unsound mem::uninitialized(), edition 2024 tightened rules around unsafe extern blocks, and Miri moved from a research project into standard CI for unsafe-heavy crates. Each change makes it structurally harder to write unsound code by accident.
Common Misconceptions
- "
unsafedisables the borrow checker" - it does not; borrowing, move semantics, and lifetimes are enforced identically inside anunsafeblock, which only adds permission for the five operations listed above. - "If it compiles and runs correctly in testing, the unsafe code is fine" - undefined behavior is a property of any possible execution, not the one you happened to test, so passing tests is weak evidence at best.
- "Only the code inside the
unsafe {}braces needs to be correct" - soundness is a property of the whole public API around that block, including every safe method that can influence the state the unsafe code reads. - "
unsafe fnis more dangerous than a safepub fnwith anunsafeblock inside" - it is a design signal about who owns the safety obligation, not a measure of danger; a poorly encapsulated safe function can be just as unsound. - "Rust programs with unsafe code are no safer than C" - the vast majority of the program, everything outside
unsafeblocks, still gets full compiler-checked safety, and the unsafe surface is explicit and searchable in a way C's implicit unsafety is not. - "Unsafe code needs to be fast, so a little sloppiness is fine" - performance and correctness are separate axes; unsound unsafe code that happens to be fast is still a critical bug waiting to be triggered.
FAQs
What is the difference between "unsafe" and "unsound"?
unsafe is a keyword marking code that performs operations the compiler cannot verify. Unsound describes a safe API that a safe caller can misuse to trigger undefined behavior. Well-written unsafe code produces sound APIs; the goal is always soundness, not merely the presence or absence of the keyword.
Does unsafe Rust turn off the type checker?
No. Type checking, trait resolution, and generic bounds all run exactly as in safe code. Unsafe only adds permission for five specific operations; it does not change how the type system evaluates the rest of the function.
Why does the standard library use so much unsafe code?
Types like Vec and String manage raw memory directly for performance in ways the borrow checker cannot express safely. The library pays that cost once, behind a reviewed and fuzzed boundary, so downstream users never have to.
How do I know if my unsafe code is sound?
No single automated check proves soundness in general. Teams combine a written # Safety argument, Miri in CI, fuzzing, and review from someone experienced with unsafe Rust. Confidence is built, not proven outright, outside narrow cases amenable to formal methods.
What is the smallest unsafe block I should write?
As small as possible, ideally a single expression like a pointer dereference, immediately guarded by a safe check. Large unsafe blocks make it harder to see which specific operation relies on which invariant, and harder to review.
Can safe code cause undefined behavior on its own?
Not by itself. UB in a Rust program always traces back to an unsafe precondition being violated somewhere, even if the violation happens deep inside a dependency. Safe code that calls an unsound safe API exposes the bug, but the root cause is the unsound unsafe implementation.
Is `unsafe { *raw_ptr }` always wrong?
No, dereferencing a raw pointer is one of the five things unsafe exists to permit. It is correct as long as the pointer is valid, properly aligned, and points to an initialized value of the right type at the moment of the dereference, conditions the programmer must guarantee some other way.
Why does `unsafe fn` push responsibility to the caller?
Because the function has no way to check its own precondition, often because the precondition is about the caller's context, like a pointer's provenance or a length invariant, rather than anything visible inside the function body. Documenting that precondition in a # Safety comment is what lets callers uphold it correctly.
What is the relationship between unsafe and FFI?
Every call into a foreign function is unsafe because the Rust compiler has no visibility into what the foreign code actually does. The usual pattern is a thin, heavily audited unsafe layer directly around the FFI calls, with a safe Rust API built on top for the rest of the codebase.
Does more unsafe code mean worse code?
Not inherently. Domains like embedded systems, custom allocators, and zero-copy parsers genuinely require unsafe operations. What matters is whether the surface is small, documented, and encapsulated behind a sound safe API, not the raw count of unsafe keywords.
How does Miri help without changing the code?
Miri interprets the program instead of compiling it natively, checking every memory access against Rust's aliasing and initialization rules as it runs. It catches UB that a normal test run would silently tolerate.
Can I write a Rust program with zero unsafe code?
Yes, and many application codebases do, relying entirely on safe abstractions from the standard library and vetted crates. #![forbid(unsafe_code)] at the crate root enforces this at compile time for teams that want the guarantee.
Related
- Unsafe Basics - the five operations
unsafeunlocks, with runnable examples - Sound Abstractions over Unsafe - patterns for encapsulating unsafe code behind a safe API
- Undefined Behavior - what UB actually is and how it manifests
- The Rust Memory Model - the layout and aliasing rules unsafe code must respect
- Miri & Sanitizers - tools for catching unsoundness before production
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.