What are the safety invariants of unsafe

Unsafe Rust requires the programmer to manually guarantee memory safety, valid pointers, and single-threaded access to mutable statics since the compiler skips these checks.

What are the safety invariants of unsafe

You're optimizing a hot loop. The profiler screams that bounds checks are killing you. You wrap the access in unsafe, drop the check, and the speed doubles. You ship it. Two weeks later, a customer reports a crash that only happens when the input size is exactly 4096 bytes. The compiler never complained. The borrow checker stayed silent. You assumed unsafe just meant "I promise this is okay," but the compiler assumed you knew the rules of the universe. You didn't. Now you're chasing a ghost in the machine.

This is the reality of unsafe. It doesn't mean "anything goes." It means "the compiler stops checking, so you must check." The rules still exist. The consequences of breaking them are worse than a runtime error. You get Undefined Behavior. UB isn't a bug. UB is the compiler saying, "I gave up. Your program can do anything now."

The contract, not the loophole

Rust's safety model rests on invariants. An invariant is a condition that must always be true for the program to behave correctly. The borrow checker enforces invariants automatically. It guarantees no null pointers, no data races, no use-after-free. When you enter an unsafe block, you are telling the compiler, "I will uphold these invariants manually. Trust me."

The compiler believes you. It generates machine code based on your promise. It removes checks. It reorders instructions. It assumes your pointers are valid. If you are wrong, the compiler's optimizations can corrupt your data in ways that make no sense. The crash might happen three functions deep. The value might be wrong only on release builds. UB breaks the link between your source code and the machine code.

Think of unsafe like climbing over the guardrail on a highway. The road is still there. The traffic is still there. The physics of gravity still apply. The guardrail just won't catch you if you slip. You need to be more careful, not less. The community treats unsafe as a tool for building safe abstractions, not a shortcut for lazy code.

Minimal example: proving the promise

Here is a raw pointer dereference. The code compiles, but only because you provide a proof.

fn main() {
    let value = 42;
    // Create a raw pointer. The compiler stops tracking this.
    let ptr = &value as *const i32;

    // SAFETY: 
    // 1. ptr is valid because it points to `value` on the stack.
    // 2. `value` is initialized with 42.
    // 3. We are only reading, so no aliasing violations occur.
    unsafe {
        println!("Value: {}", *ptr);
    }
}

The cast &value as *const i32 is safe. Creating a raw pointer from a reference never causes UB. The danger is the dereference *ptr. That operation requires unsafe. The // SAFETY comment is not optional flavor text. It is a proof obligation. It lists the invariants you verified. If you can't write the proof, you don't have safety.

What makes a pointer safe?

When you dereference a raw pointer, you must guarantee four things. Missing any one of them is UB.

Validity. The pointer must point to allocated memory, or one byte past the end of an allocation. It cannot be null unless the type allows null pointers (like Option<&T>). It cannot be a dangling pointer to freed memory. The compiler assumes valid pointers stay valid. If you dereference a dangling pointer, the compiler might optimize away a null check elsewhere, causing a crash.

Initialization. The memory must be initialized. You cannot read from uninitialized memory. If you allocate raw memory with std::alloc::alloc, it contains garbage. Reading that garbage is UB. You must write to it first, or use MaybeUninit to handle the uninitialized state safely. The compiler assumes initialized memory holds a valid value. If you lie, the compiler might assume the value is a specific constant and delete code.

Alignment. The pointer must be aligned to the type's alignment requirement. An i32 usually requires 4-byte alignment. If the address is not a multiple of 4, you get UB. On some CPUs, this causes a hardware fault. On others, it works but runs slowly. The compiler assumes alignment holds. It might generate instructions that require alignment. If you violate alignment, the instruction fails.

Aliasing and races. This is the hardest part. If you have a *mut T, you must be the only one accessing that memory. No other pointer, reference, or thread can touch it. If you have a *const T, others can read too, but no one can write. Violating aliasing is UB. The compiler reorders reads and writes based on aliasing rules. If you have two *mut pointers to the same location, the compiler might cache a value in a register and never reload it, so your update disappears. Data races are also UB. If two threads access the same memory and one writes, the behavior is undefined. The compiler assumes no races. It might eliminate locks or reorder memory accesses.

Treat these four invariants as the law. If your code breaks any of them, it is broken. Period.

Realistic example: wrapping the danger

In real code, you rarely write unsafe in main. You write unsafe inside a function that exposes a safe API. The function takes the burden of proof. The caller gets safety.

/// Reads bytes from a raw pointer into a Vec.
/// 
/// # Safety
/// The caller must guarantee that `ptr` is valid for reads of `len` bytes,
/// properly aligned for `u8`, and not modified concurrently.
pub unsafe fn read_bytes(ptr: *const u8, len: usize) -> Vec<u8> {
    // SAFETY: 
    // 1. `ptr` is valid for `len` bytes per the function contract.
    // 2. `ptr` is aligned for `u8` (alignment of 1 is always satisfied).
    // 3. No concurrent modification per the function contract.
    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
    slice.to_vec()
}

The /// Safety section in the doc comment is the contract. Anyone calling this function must read it. If they pass a bad pointer, they violate the contract and get UB. The // SAFETY comment inside the block is the proof. It maps the contract to the specific unsafe operation. The community convention is to keep unsafe blocks small. Here, the block wraps only the slice creation. The rest of the function is safe. This minimizes the surface area where you can make a mistake.

Notice the alignment check. u8 has alignment 1, so any address is valid. If this were *const i32, you would need to verify alignment in the proof. The convention is to document the alignment requirement in the /// Safety section. Callers need to know.

Convention aside: Run your unsafe code with cargo miri. Miri is a tool that runs your code in a virtual machine and detects UB. It catches invalid pointers, uninitialized reads, and alignment violations. If Miri passes, your invariants are likely solid. The community uses Miri as the gold standard for testing unsafe code. It's the only way to sleep at night.

Pitfalls and compiler errors

The compiler helps you avoid accidents, but it won't save you from UB.

If you try to dereference a raw pointer outside an unsafe block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe block or function). This is a feature. The compiler forces you to acknowledge the risk. You must wrap the operation in unsafe and provide a proof.

A common pitfall is static mut. Accessing a static mut variable is always unsafe, even in a safe function. The compiler cannot track mutations across the whole program. Data races on static mut are UB. Use std::sync::atomic or Mutex instead. If you must use static mut, wrap it in a safe function that guarantees single-threaded access.

Another pitfall is assuming unsafe disables all checks. It doesn't. Type checks still apply. If you cast a pointer to the wrong type, you get a compile error. unsafe only disables safety checks like bounds, null, and aliasing. You still need to match types.

Watch out for UB optimizations. The compiler might delete code it thinks is unreachable based on your invariants. If you dereference a null pointer, the compiler assumes that branch never executes. It might remove a panic! that should have caught the error. Your code crashes silently. This is why UB is insidious. It breaks the logic of your program, not just the memory.

Don't trust your eyes. Trust the invariants. If the invariants hold, the code is safe. If they don't, nothing else matters.

When to use unsafe

Use unsafe when you are building a safe abstraction that requires raw pointer manipulation, such as a custom allocator, a ring buffer, or a linked list. Use unsafe when you are performing FFI and must pass raw pointers to C libraries that expect them. Use unsafe when profiling proves that safe bounds checks are the bottleneck and you can mathematically prove the index is always valid. Reach for std::cell::Cell or std::rc::Rc when you need interior mutability; the borrow checker can handle this safely. Pick std::sync::Mutex when multiple threads need to share data; static mut is almost never the right choice. Avoid unsafe when you are just trying to silence a compiler warning; the warning is usually telling you something important.

Write safe code first. Reach for unsafe only when you have a hammer and a nail, and you've measured the wall.

Where to go next