Best Practices for Unsafe Code in Rust

Use unsafe Rust like a maintenance bypass switch: keep blocks small, document invariants, and hide them behind safe abstractions. Covers what the keyword unlocks, the safe-abstraction pattern, undefined behavior, and when to reach for safe alternatives instead.

When safe Rust hits a wall

You are writing a high-performance parser. The safe Rust code handles the grammar fine, but the inner loop chokes on memory allocations. You try to slice a pre-allocated buffer, but the borrow checker complains because another module holds an immutable reference. You know the logic is sound. You know the references will not overlap at runtime. The compiler just cannot see that far ahead. You reach for unsafe.

The first time you type unsafe, it feels like breaking a rule. In many languages, "unsafe" means "this might crash" or "this is bad practice." In Rust, the keyword means something entirely different. It means "the compiler cannot verify the safety of this operation, so I, the programmer, take full responsibility."

The contract behind the keyword

Adding unsafe to your code does not turn off all rules. You can still have type errors. You can still have logic bugs. unsafe only unlocks five specific operations the compiler normally forbids: dereferencing a raw pointer, calling an unsafe fn, accessing a mutable static variable, accessing union fields, and implementing an unsafe trait.

Think of unsafe like a maintenance bypass switch on an industrial machine. The standard controls have interlocks that prevent you from opening a panel while the gears are turning. Safe Rust is those interlocks. When you flip the bypass switch, the interlocks disengage. The machine does not become safer. The gears still turn. You simply acknowledge that you have manually verified the machine is stopped, and you are choosing to proceed without the automatic safeguards.

Most of the time, you hit this wall because of raw pointers. Raw pointers (*const T or *mut T) are just memory addresses. They are essentially typed integers. They do not track lifetimes. They do not track mutability. They do not guarantee the memory is valid. Rust refuses to let you read or write through them without unsafe because a raw pointer might point to garbage, to freed memory, or to an address that causes a hardware fault.

Minimal example: Dereferencing a raw pointer

Here is the simplest case. You have a value, you turn it into a raw pointer, and you want to read it back.

/// Demonstrates dereferencing a raw pointer within an unsafe block.
fn main() {
    let x = 42;

    // Convert reference to raw pointer to bypass borrow checker tracking.
    // This creates a pointer that the compiler does not manage.
    let ptr = &x as *const i32;

    // The compiler blocks dereferencing raw pointers in safe code.
    // We wrap the operation in unsafe to take responsibility.
    let value = unsafe {
        // SAFETY:
        // 1. ptr derives from &x, so it is non-null and properly aligned.
        // 2. x remains in scope, so the memory is valid and initialized.
        // 3. No other references to x exist, preventing data races.
        *ptr
    };

    println!("Value: {}", value);
}

When this compiles, Rust checks the types. It sees ptr is a *const i32. If you try to dereference it in safe code, the compiler rejects it with E0133 (dereference of raw pointer requires unsafe). You add the unsafe block. The compiler accepts the code and moves on. At runtime, the CPU reads the address and returns 42.

The // SAFETY: comment acts as a checklist. You verify the invariants before you write the block. If x went out of scope, dereferencing ptr would trigger undefined behavior. Trust the borrow checker. It usually has a point.

How the compiler and CPU handle it

The unsafe keyword does not disable the borrow checker for your entire program. It only applies to the specific block it wraps. Everything outside that block remains strictly checked. The compiler still verifies types, lifetimes, and trait bounds. It only steps back from the memory safety guarantees inside the braces.

At the CPU level, nothing special happens. The compiler emits the same machine instructions it would for a C program. The difference is entirely in the Rust type system and the guarantees you are making to the optimizer. When you write safe Rust, the compiler assumes references are always valid. It uses that assumption to reorder memory accesses, eliminate bounds checks, and inline functions aggressively. When you write unsafe, you are telling the compiler that you have manually upheld those assumptions.

This distinction matters because the compiler will optimize around your unsafe code. If you violate the invariants, the compiler is not required to warn you. It is not required to panic. It simply assumes your code is correct and generates the fastest possible machine code based on that assumption. Treat the // SAFETY: comment as a formal proof. If you cannot write the invariants, you do not have a safe abstraction.

The safe abstraction pattern

The standard library is full of unsafe. The goal is not to avoid it entirely. The goal is to isolate it. If you sprinkle unsafe blocks across your codebase, you make safety impossible to verify. Every caller must read your block and manually check the invariants. Instead, you write a small unsafe block inside a function and expose a safe API to the world. The safe function validates the preconditions before calling the unsafe part.

Imagine you are wrapping a C library. The C function expects a raw pointer. You want to call it from Rust without handing your users a loaded gun.

/// Reads an i32 from a raw pointer, returning None if the pointer is null.
///
/// This function provides a safe interface by checking invariants before
/// accessing memory. The caller cannot trigger undefined behavior.
fn read_value(ptr: *const i32) -> Option<i32> {
    // Validate the pointer before entering unsafe.
    // A null pointer dereference causes undefined behavior.
    if ptr.is_null() {
        return None;
    }

    // The unsafe block is small and isolated.
    // The safe wrapper enforces the non-null invariant.
    Some(unsafe {
        // SAFETY:
        // 1. ptr is non-null (checked above).
        // 2. Caller guarantees ptr points to an initialized, aligned i32.
        // 3. No concurrent writes occur to this address.
        *ptr
    })
}

fn main() {
    let x = 99;

    // Create a raw pointer to pass to the safe wrapper.
    // The caller does not need to use unsafe here.
    let ptr = &x as *const i32;

    // The caller interacts only with the safe API.
    // The unsafe implementation is hidden.
    match read_value(ptr) {
        Some(val) => println!("Got {}", val),
        None => println!("Pointer was null"),
    }
}

This is the safe abstraction pattern. The unsafe is confined to one place. The invariants are checked before the block. The public API remains safe. If you make a mistake inside the block, only this function is broken. The rest of your program stays protected. The community calls this the "minimum unsafe surface" rule. Keep the block as small as possible. Put the validation logic outside. In production code, every unsafe block must carry a // SAFETY: comment. This is not optional. It is the community standard for code review. If a reviewer cannot verify the comment, the PR gets rejected.

The danger of undefined behavior

What happens if you lie in your unsafe block? If you dereference a dangling pointer, Rust does not panic. It triggers undefined behavior.

Undefined behavior is the worst outcome in programming. The program can do anything. It might crash immediately. It might return a wrong value. It might look like it works perfectly in testing but silently corrupt your database in production. The compiler assumes undefined behavior never happens. It uses that assumption to optimize your code. If you have undefined behavior in your program, the compiler might delete your safety checks.

Consider a function that dereferences a pointer before checking for null.

fn risky(ptr: *const i32) -> i32 {
    // This line causes undefined behavior if ptr is null.
    // The compiler sees the dereference and assumes ptr cannot be null.
    let val = unsafe { *ptr };

    // The null check happens after the undefined behavior.
    // The compiler may optimize this check away entirely.
    if ptr.is_null() {
        return 0;
    }

    val
}

In this function, the dereference happens before the null check. If ptr is null, undefined behavior occurs immediately. The compiler knows undefined behavior is forbidden by the language specification. It concludes that ptr cannot possibly be null. It removes the if check entirely. Now, when you call this with a null pointer, the behavior is completely unpredictable. The fix is to check invariants before you enter the unsafe block. Never rely on checks that happen after undefined behavior has already occurred.

Another pitfall is misunderstanding unsafe fn. An unsafe function does not mean the function is dangerous to call. It means the function has preconditions the compiler cannot enforce. If you call an unsafe function and satisfy its preconditions, the call is safe. The keyword is a warning label about requirements, not a guarantee of danger. Beginners often reach for unsafe when they fight the borrow checker. The borrow checker usually has a point. If the compiler rejects your code, it often means the design needs adjustment, not that you need to bypass safety. Try to restructure the data or change the ownership model before adding unsafe. Most collection manipulations can use Vec. Shared ownership can use Rc or Arc. Interior mutability can use Cell or RefCell.

Verifying your assumptions with tooling

Writing unsafe code requires a different debugging mindset. You cannot rely on the borrow checker to catch mistakes. You need tools that simulate undefined behavior at runtime.

Miri is an interpreter for Rust's mid-level intermediate representation. It runs your code in a simulated environment and catches undefined behavior that the compiler misses. It detects out-of-bounds accesses, use-after-free, data races, and uninitialized memory reads. Running cargo miri test on your crate is the standard way to verify unsafe blocks before release.

AddressSanitizer and UndefinedBehaviorSanitizer are LLVM-based runtime detectors. You enable them with environment variables: RUSTFLAGS="-Z sanitizer=address" cargo run. They instrument your binary to catch memory errors at runtime with minimal overhead. They are slower than native execution, but they catch bugs that slip past Miri because of complex system interactions.

Use these tools as part of your CI pipeline. Do not treat them as optional. If your unsafe code passes Miri and the sanitizers, you have strong evidence that your invariants hold. Treat tooling as your safety net. The waiver only works if you actually check the ropes.

When to use unsafe versus safe alternatives

Use unsafe for FFI when you call C or another language and have to cross out of Rust's safety guarantees. Use unsafe for performance-critical inner loops where measured profiling shows safe abstractions are the bottleneck, and isolate the block in a tiny helper. Use unsafe when you are implementing a safe abstraction yourself, like a custom allocator, a linked list, or a zero-copy parser. Reach for plain references when lifetimes are simple; the unsafe alternative is rarely worth it. Reach for Vec or HashMap when you need dynamic collections; the overhead is negligible compared to the cost of debugging memory bugs. Reach for Rc or Arc when multiple owners need to read the same data. Reach for RefCell or Mutex when you need interior mutability.

If you are a beginner, you will rarely need unsafe. If you find yourself writing unsafe in a simple script, pause and look for a safe alternative. Usually, there is one. The goal is to write code that is correct and fast. Safe Rust gets you 95% of the way there. unsafe is for the last 5% where you need raw control. Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Keep it small. Keep it documented. Keep it tested.

Where to go next