Systems Programming · Unsafe Code

What the Unsafe Keyword Actually Disables

The unsafe marker gates a short, enumerable list of operations rather than switching off the type system, and the obligations it transfers reach past the block that names it.

A persistent misreading of Rust holds that unsafe turns off the borrow checker. It does not. A program that tries to hold two mutable references to the same local variable is rejected whether or not the offending lines sit inside an unsafe block. The keyword is not a mode switch over the type system. It is a permission marker over a fixed list of operations that the compiler declines to police, and everything outside that list keeps being checked exactly as before.

The distinction matters because the marker is often treated as a place where normal reasoning stops. In practice the opposite is true: unsafe is where reasoning is transferred from the compiler to the programmer, and the transferred obligations are usually larger than the region the keyword annotates.

The short list of gated operations

The Rust Reference enumerates five categories of operation that require an unsafe context. The first is dereferencing a raw pointer, either *const T or *mut T. The second is calling a function or method declared unsafe. The third is reading or writing a static mut item. The fourth is implementing an unsafe trait. The fifth is accessing a field of a union, since the compiler cannot know which variant is currently the active one.

Nothing on that list involves code generation. An unsafe block emits the same machine code as the equivalent operations would emit without it, and no runtime check is inserted or removed. The block is a lexical annotation consumed by the compiler’s safety analysis and then discarded. Because of this, unsafe has no effect on the behaviour of a program that was already correct, and it does not make an incorrect program fail earlier or more loudly.

The list is short for a reason. Each entry names an operation whose validity depends on facts the compiler cannot recover from the types. A raw pointer’s type records the pointee’s layout but not whether the address is live, aligned, or derived from a valid allocation. A static mut has no owner, so no borrow discipline can be attached to it. A union’s active field is a runtime property that the type system deliberately does not track. Requiring a marker at those sites creates a searchable inventory of the places where the compiler has stepped back.

What keeps being checked inside the block

Inside an unsafe block, ownership, moves, drops, type checking, trait resolution, and the borrow checker all continue to operate. So do bounds checks on ordinary indexing. The following does not compile, and the unsafe block changes nothing about that:

let mut value = 0u32;
let a = &mut value;
let b = &mut value;      // error: cannot borrow `value` as mutable more than once
unsafe {
    *a = 1;
    *b = 2;
}

The borrow checker rejects the second borrow before the unsafe block is even considered. What the block would permit is a different construction: converting a into a raw pointer, converting b into a second raw pointer, and dereferencing both. That compiles, and it is undefined behaviour, because two live &mut aliases to the same location violate the aliasing model the optimiser relies on. The compiler has not stopped checking anything it was able to check; it has stopped being able to check this.

That asymmetry is the practical content of the keyword. The five gated operations are precisely those where the missing fact is not a typing fact but a fact about the runtime state of memory. Bounds checks stay because the length is known at the indexing site. Pointer dereferences lose their check because the length and liveness of the target are not part of the pointer’s type.

The contract belongs to the function, not the block

An unsafe fn carries a contract in its documentation rather than in its signature. The canonical example is the constructor for a shared slice from raw parts, whose documented preconditions are worth reading as a template. The pointer must be non-null and properly aligned for the element type. It must be dereferenceable for len consecutive elements, all of which must be initialised. The memory must belong to a single allocation, and the total size in bytes must not exceed isize::MAX, which is 2^63 - 1 on a 64-bit target. Finally, the caller must ensure the memory is not mutated for the lifetime of the returned slice, except inside an UnsafeCell.

Six conditions, none of them expressible in the type (*const T, usize) -> &[T]. The signature is identical whether the caller respects all six or none of them.

An unsafe fn therefore does not mean “this function is dangerous to call”. It means “this function’s correctness depends on conditions the caller must establish”. Since the 2024 edition, the lint that requires an explicit unsafe block inside an unsafe fn for each unsafe operation is on by default, so the body no longer reads as one undivided unsafe region. The split is worth making explicit, because a function can be unsafe to call for reasons that have nothing to do with the unsafe operations it performs internally, and conflating the two hides which obligation belongs to whom.

Why the boundary sits there

The placement of the line follows from decidability and from tolerance for false rejections. A compiler could, in principle, require a proof that a raw pointer is live before every dereference, but the proof would need a whole-program points-to analysis and would still fail on code that builds addresses at runtime, which is most systems code. Requiring the marker is the cheap approximation: it accepts every program that the programmer is willing to vouch for, and it localises the vouching.

The alternative design is visible in C, where no marker exists and the entire translation unit is unchecked in this sense. A C compiler will happily dereference a null pointer, index past an array, or call through a function pointer of the wrong type, and it may use the assumption that such things do not happen to justify transformations. Rust’s marker does not remove that freedom; it makes it visible and countable.

The cost is that the marker is coarse. A single unsafe block can contain several unrelated obligations, and the block does not record which invariant each line depends on. Where a block is small and its precondition is written down, the audit is straightforward. Where a block spans forty lines and the invariants live only in the author’s head, the marker records that something was vouched for without recording what.

Failure modes that outlive the block

The most common structural error is treating the end of the block as the end of the obligation. Undefined behaviour is a property of an execution, not of a lexical region, so an invariant broken inside a block can be violated by safe code afterwards. Resizing a vector’s length through its raw-parts API and then indexing it from safe code reads uninitialised memory; the read is safe code, the corruption was set up earlier, and the compiler has no way to connect the two.

A second failure mode is the unsound safe wrapper, which is strictly worse than an honest unsafe fn. If a safe function can be called without preconditions and yet can produce undefined behaviour, the guarantee that safe code cannot cause memory unsafety is gone for the whole crate graph, and no amount of care at the call site recovers it. The obligations that make such a wrapper sound are the subject of What Makes a Safe Wrapper Around Unsafe Code Sound.

A third is provenance loss, where a pointer is round-tripped through an integer and back. That conversion is legal to write and frequently wrong to execute, and the reasons are covered in Pointer Provenance and the Cost of Integer Round-Trips.

None of these are caught by the marker. What the marker provides is an audit surface: a crate can deny unsafe_code at the crate root and require every exception to be a visible, reviewable annotation. The value of the keyword is not that it makes unsafe code safe. It is that it makes the count of unchecked assumptions finite and greppable, which is the property that makes the rest of the reasoning possible.

References

  1. The Rust Reference: Unsafe blocks — Rust project
  2. The Rustonomicon: Meet Safe and Unsafe — Rust project
  3. The Rust Reference: Behavior considered undefined — Rust project
  4. ISO/IEC 9899:2011, 6.5 Expressions — ISO/IEC