The Rust Engineering Handbook / Chapter 66
The Unsafe Contract and Safety-Case Method
Treat every unsafe operation as an explicit obligation handoff, isolate it behind invariants, and review it through a durable safety case.
Up to this point, the handbook has treated Rust as a network of compiler-enforced and runtime-enforced contracts. Ownership rejects invalid reuse. References carry aliasing and validity expectations. Types exclude states. Bounded async structures make capacity visible. Part XI begins where an implementation needs a claim the compiler cannot establish by itself.
The unsafe keyword does not turn those contracts off. It marks an obligation boundary. One form declares that an obligation exists; another form asserts that somebody has discharged it. The engineering task is to identify that somebody, write the exact proposition they must prove, restrict the state on which the proof depends, and preserve evidence as the code evolves.
This is why an unsafe block is not a risk label or a request for extra caution. It is a proof assertion. A reviewable unsafe abstraction makes each assertion small enough to connect to a caller contract, a representation invariant, a local check, or a documented platform guarantee.
Two directions across the boundary
Rust uses unsafe in two complementary directions.
Declaring an obligation tells another party that the compiler does not verify all preconditions:
- an
unsafe fngives extra safety conditions to its caller; - an
unsafe traitgives extra conditions to every implementation; - an unsafe external item declaration requires the declaration to describe reality correctly.
Discharging an obligation states that the current code has established those conditions:
- an
unsafe { ... }block permits unsafe operations and asserts their preconditions; - an
unsafe implasserts that an implementation upholds an unsafe trait’s contract; - an unsafe attribute such as
#[unsafe(no_mangle)]asserts its global conditions.
These forms are not interchangeable. Marking a function unsafe moves obligations to callers; it does not prove unsafe operations in the body correct. An unsafe block inside a safe function keeps the public obligation with the implementation: safe callers may use the function with ordinary typed inputs, so the implementation must prevent them from triggering undefined behavior.
The Rust Reference’s language is useful: unsafe can create or discharge an obligation to prove something safe. That model scales from one unchecked index to an allocator, an FFI wrapper, or a concurrency primitive.

What is unsafe, and what remains checked
The language designates a limited set of operations as unsafe because they can violate memory-safety guarantees when their extra conditions are false. The current Reference includes raw-pointer dereference, calls to unsafe functions, access to a union field, use of mutable or unsafe external statics, implementation of unsafe traits, external declarations, and unsafe attributes. Some target-feature calls also carry safety restrictions.
Inside an unsafe block, normal Rust rules continue to apply. Moves still move. Borrow checking still checks references. Visibility still controls access. Types still need valid values. Drop still runs according to Rust semantics. The block permits designated unsafe operations; it does not create a language-free zone.
Nor does unsafe make an operation incorrect. Standard-library collections and synchronization primitives use unsafe internals to provide sound safe APIs. The relevant question is whether every unchecked precondition is true for every execution safe callers can cause.
Several serious defects do not require unsafe code and are not undefined behavior: returning the wrong account balance, deadlocking, leaking memory, mishandling authorization, exposing a secret, or duplicating a distributed effect. These are correctness, liveness, security, or operational failures. Conversely, a function can return the intended answer in tests and still be unsound if a safe input can reach undefined behavior.
Use the terms precisely:
- sound safe API: safe callers cannot cause undefined behavior through the API while satisfying its ordinary typed contract;
- unsafe operation: an operation whose extra safety conditions are not fully checked by the compiler at that point;
- unsafe function: a callable boundary with documented conditions the caller must uphold;
- validity invariant: conditions required for a bit pattern or stored state to be a value of its type;
- correctness invariant: broader functional behavior, which may fail without undefined behavior.
“It passed tests” is evidence about exercised behavior. It is not the definition of soundness because the claim quantifies over all safe callers and all relevant executions.
Rust 2024 separates caller duty from body assertions
Historically, the body of an unsafe fn implicitly permitted unsafe operations. That blurred two jobs: declaring caller obligations and asserting that operations inside the function satisfy theirs. In Rust 2024, the unsafe_op_in_unsafe_fn lint warns by default. The preferred style uses explicit unsafe blocks even inside unsafe functions.
The fixture makes the distinction visible:
/// # Safety
/// For `'a`, `ptr..ptr.add(len)` must be a live, readable byte range
/// inside one allocation, and it must not be mutated.
pub unsafe fn from_raw_parts(
ptr: *const u8,
len: usize,
) -> Result<AsciiField<'a>, FieldError> {
// SAFETY: The caller establishes the preconditions required by
// `slice::from_raw_parts`; the slice is used read-only for `'a`.
let bytes = unsafe { slice::from_raw_parts(ptr, len) };
AsciiField::parse(bytes)
}
The unsafe fn declaration says what the caller owes. The small block says why this particular call to slice::from_raw_parts may rely on those facts. The safe parser then checks a different class of properties: nonempty length, a 64-byte maximum, ASCII, and absence of control bytes.
Rust 2024 also requires external blocks to be written as unsafe because incorrect foreign declarations can cause undefined behavior. Individual functions within an unsafe external block may be declared safe only when calls with any permitted typed arguments are safe. Attributes including no_mangle, export_name, and link_section use #[unsafe(...)] syntax because they can violate global symbol or placement constraints. A safety comment for such an attribute must address those global constraints; “needed by the linker” is not a proof.
Do not suppress unsafe_op_in_unsafe_fn across a crate to preserve old style. Migration can be mechanical, but review must still decide whether each new block has the right scope and justification. The fixture sets the lint to deny and enables Clippy’s undocumented_unsafe_blocks lint. Lints improve visibility; they cannot verify the comments.
Build the safety case from propositions
A safety comment should state why preconditions are true at one operation. A safety case records the larger argument that makes those comments stable under change. For the fixture, the core table is:
| Assumption or invariant | Established by | Required by | Failure if false | Validation |
|---|---|---|---|---|
raw range is non-null, aligned, live, readable, and within one allocation for 'a |
caller of from_raw_parts |
slice::from_raw_parts |
invalid reference or out-of-bounds access | API docs, audited call sites |
len <= isize::MAX and range does not wrap |
caller | slice construction rules | invalid slice metadata/range | API docs, boundary adapter checks |
memory is not mutated for 'a |
caller and foreign owner | shared slice reference | aliasing violation | ownership protocol review |
| bytes are ASCII and non-control | validate |
from_utf8_unchecked and field policy |
invalid UTF-8 if ASCII check is removed; policy error for controls | unit tests, private fields |
every AsciiField was validated |
private field plus constructors | safe as_str |
safe caller reaches unchecked conversion on invalid bytes | module privacy, constructor inventory |
The table forces four distinctions.
First, not every property belongs to the caller. Raw memory lifetime and readable range cannot be reconstructed after an arbitrary pointer arrives, so the unsafe constructor assigns them to its caller. Byte content can be checked cheaply, so the constructor keeps that burden.
Second, every property needs an owner. “Pointer is valid” is too vague. Valid for how many bytes, for which operations, for what lifetime, under what mutation and thread behavior?
Third, the failure column must name undefined behavior only where appropriate. A control byte violates the field’s business policy but does not make UTF-8 invalid. Conflating policy with safety encourages unnecessary unsafe APIs and obscures the real proof.
Fourth, validation evidence differs from proof. Unit tests exercise representative bytes. Privacy prevents arbitrary safe construction. Call-site audits inspect the raw ownership protocol. Dynamic tools may catch violations in executed paths. None alone establishes the universal claim.
Privacy bounds the trusted state
Unsafe code often depends on safe code that establishes or preserves an invariant. An unchecked read may be sound because safe code checked a bound. A raw allocation write may be sound because private fields accurately track length and capacity. Changing the safe check or field update can make the unsafe operation unsound without editing the unsafe block.
Therefore, line count is not the only unsafe-surface metric. Count the trusted computing base: all functions, fields, trait implementations, callbacks, build settings, platform assumptions, and external components whose behavior the proof needs. One two-line block can have a module-wide proof surface.
Privacy is a proof tool. If representation fields are private and every constructor establishes the invariant, safe code outside the module cannot fabricate invalid state. If a method exposes &mut access to invariant-bearing fields, returns a raw pointer without a lifetime protocol, or allows unreviewed trait implementations to participate, the trusted boundary expands.
AsciiField keeps its byte slice private. Its safe constructor validates. Its unsafe constructor converts a caller-proven raw range into a slice and then delegates to the same validator. The unchecked UTF-8 conversion depends on the constructor inventory and private field, not only on its adjacent comment. Review must therefore search every construction path when the invariant changes.
Module privacy is not a substitute for documentation. It makes the state space governable. The safety case should identify which safe functions are trusted to preserve the invariant and require unsafe review when they change, even if their diff contains no unsafe token.
Shrink assertions, not context
Consider an oversized block:
unsafe {
let bytes = slice::from_raw_parts(ptr, len);
validate(bytes)?;
metrics.record(bytes.len());
cache.insert(key, bytes.to_vec());
Ok(AsciiField { bytes })
}
Only slice construction needs unsafe permission. Validation, metrics, allocation, caching, and result construction are safe operations. Leaving them inside the block creates several review problems:
- the safety comment cannot attach to one operation;
- a later unsafe call can enter unnoticed inside an already broad scope;
- panics and callbacks inside unrelated code complicate the proof story;
- reviewers must inspect more statements to find the assertion;
- the block suggests that unsafe permission somehow protects the later operations.
Reduce it:
// SAFETY: The caller's contract establishes a readable, immutable range for `'a`.
let bytes = unsafe { slice::from_raw_parts(ptr, len) };
validate(bytes)?;
metrics.record(bytes.len());
cache.insert(key, bytes.to_vec());
Ok(AsciiField { bytes })
Minimal scope does not mean minimal explanation. The comment may reference a representation invariant or earlier check, but it should name the exact preconditions discharged here. Avoid comments such as “safe because checked,” “pointer comes from C,” or “required for performance.” They omit the proposition.
There are cases where several operations belong in one block because they implement one indivisible invariant transition. Keep them together only when splitting would hide the relationship or expose a transient state across safe code that may panic or re-enter. Then explain the transition, panic behavior, and why no observer can see invalid intermediate state.
Caller and implementer contracts need different review paths
An unsafe function is appropriate when no safe signature can express or check all preconditions and the caller genuinely possesses the evidence. Raw-pointer boundary adapters are common examples. Do not mark a function unsafe merely because its implementation contains unsafe code. If typed inputs and runtime checks are sufficient, expose a safe function and keep the proof inside.
An unsafe trait is more consequential. It tells every implementation that violating the documented contract may let safe users trigger undefined behavior. The trait documentation must state obligations for all methods, associated items, auto-trait interactions, and allowed state changes. An unsafe impl asserts the entire implementation satisfies them. This deserves specialist review even when the impl body contains no unsafe block.
Send and Sync are familiar unsafe traits, but custom unsafe traits should be rare. If violating a trait law can only produce a wrong answer or panic, the trait probably needs a documented correctness law, not unsafe trait. Unsafety is for conditions whose violation can undermine memory safety of safe code relying on the implementation.
Unsafe external declarations assign a different duty: the Rust declaration must match the foreign symbol’s ABI, argument and return representation, calling conditions, and mutability/lifetime behavior. The presence of an external library or generated binding does not discharge the proof. Chapters 72–73 develop that boundary; here the governing principle is that declarations are claims about reality.
Unsafe attributes often require repository-wide evidence. Two exported items with the same symbol name, an incorrect section placement, or a symbol contract that disagrees with consumers can break assumptions outside the local module. Record naming ownership, linker/platform scope, and collision checks alongside the item.
Soundness and correctness fail differently
Suppose AsciiField::parse accidentally accepts a tab. If the API promises printable fields without control bytes, that is a correctness defect. ASCII tab is still valid UTF-8, so as_str remains memory-safe. Suppose instead a new constructor stores arbitrary bytes without validation. Safe callers can then reach from_utf8_unchecked with invalid UTF-8. The abstraction becomes unsound.
The distinction affects severity and evidence. Unsoundness can let safe code trigger undefined behavior and requires urgent containment. Correctness defects may also be severe—authorization and cryptographic errors demonstrate that memory safety is not a complete security model—but they need a different causal statement.
Avoid claiming that unsafe code is the only source of undefined behavior in an engineering system. Incorrect compiler flags, foreign code, hardware behavior outside assumptions, or violating platform contracts can participate. Within Rust’s language model, the safety case should say which documented guarantees and external assumptions it relies on.
Unsafe is also not automatically faster. Replacing a checked operation with an unchecked one spends review and maintenance budget and may not change optimized machine code. Require a benchmark or an expressiveness need. “The compiler would not accept my design” is a signal to inspect the design and proof, not sufficient justification to introduce unsafe code.
A durable safety-case dossier
For each unsafe abstraction, maintain an artifact with these sections:
- Safe API claim: what safe callers may do without causing undefined behavior.
- Representation invariant: the valid states of stored fields and external resources.
- Unsafe operation inventory: location, operation, exact preconditions, and how each is established.
- Construction proof: why every constructor creates valid state.
- Preservation proof: why every safe method and trait implementation preserves it.
- Panic and drop analysis: transient states, unwinding, leaks, double drop, and cleanup.
- Concurrency analysis:
Send,Sync, aliasing, callbacks, thread affinity, and synchronization. - Validation evidence: tests, Miri, sanitizers, fuzzing, compiler lints, and audited callers, with limits.
- Residual risk and change control: assumptions not mechanically verified and reviewers required for change.
This opening method is intentionally structural. The following unsafe chapters supply the detailed validity, provenance, representation, drop, FFI, and tool models needed to fill it for harder abstractions. Do not invent answers prematurely. A safety case may explicitly mark an external allocator contract or evolving pointer rule as an assumption requiring specialist review.
Treat the dossier as code-adjacent design documentation. Link operation rows to source locations and tests. Require updates when a trusted safe method, representation field, unsafe trait implementation, feature, target, panic strategy, or dependency changes. A stale safety comment is worse than an absent one when it reassures reviewers with an obsolete argument.
Exercise: reduce and defend an unsafe boundary
Take a module containing one unsafe function with a block that includes raw slice construction, validation, logging, allocation, and caching.
Produce:
- a caller contract specifying allocation, bounds, alignment, lifetime, mutation, and thread assumptions;
- an inventory of every unsafe operation and unsafe annotation;
- a rewrite that reduces unsafe blocks to the operations that need permission;
- a proof-obligation table with owner, consumer, failure, and validation columns;
- a representation invariant and constructor inventory;
- a soundness-versus-correctness classification for five plausible defects;
- a panic, drop, and callback-reentrancy analysis;
- a change-control rule naming safe code whose modification triggers unsafe review;
- a test and dynamic-analysis plan that states what each tool cannot prove;
- a residual-risk decision: accept, redesign, isolate behind a process boundary, or reject.
Reject the artifact if it uses “valid pointer” without dimensions, cites passing tests as proof of soundness, moves checkable byte validity to callers, marks the whole safe wrapper unsafe, or narrows the lexical block while leaving an undocumented module-wide invariant.
Unsafe review questions
- Is unsafe necessary for expressiveness, boundary integration, or measured performance?
- Does each declaration say who receives which extra safety conditions?
- Does each unsafe block or impl state the exact conditions it asserts?
- Are checkable conditions validated before the unsafe operation?
- Is the lexical block small, and is the trusted computing base identified?
- Can safe code outside the module fabricate or corrupt invariant-bearing state?
- Have all constructors, mutation paths, trait impls, callbacks, panic paths, and drop paths been inventoried?
- Are soundness obligations separated from functional and security requirements?
- Does Rust 2024 require an explicit block, unsafe external declaration, or unsafe attribute form here?
- Are lints enabled without being mistaken for proof?
- Does evidence cover caller boundaries and multiple targets, and are its blind spots recorded?
- Will changes to trusted safe code trigger specialist unsafe review?
The unsafe-contract-lab deliberately contains two small unchecked operations: raw slice construction behind an unsafe caller contract and UTF-8 conversion behind a private ASCII invariant. Four tests cover valid safe construction, rejected content, a valid raw boundary, and invalid content arriving through a valid raw range. Rust 1.97.0 checks and Clippy pass with explicit unsafe operations required and undocumented blocks denied. That evidence supports the example; the argument about lifetime, allocation, and mutation still comes from the caller contract and review.
The next chapter tightens the vocabulary around initialized memory, typed validity, MaybeUninit, partial construction, and manual drop. Those mechanisms are easier to misuse when the safety case begins as a vague promise. Start with propositions and owners; only then add lower-level operations.
Sources and version note
The Rust Reference’s unsafe keyword and unsafety chapters define the obligation-creation/discharge model, list language-level unsafe operations, describe unsafe functions, blocks, traits, impls, external declarations, and unsafe attributes, and note the Rust 2024 external-block change. The Edition Guide documents the Edition 2024 unsafe_op_in_unsafe_fn behavior and recommends explicit blocks. The Rustonomicon’s working-with-unsafe discussion explains how sound unsafe operations can depend on invariants maintained by safe code and why privacy bounds that dependency; it is supporting guidance rather than a replacement for the Reference. These sources were reviewed on 2026-07-12 against Rust 1.97.0.
Continue reading
Full table of contents