What Is Unsafe Rust and When Should You Use It?

Unsafe Rust bypasses safety checks for low-level operations and should only be used when safe Rust is insufficient.

When safe Rust hits the wall

You are building a parser for a binary file format. You have the data in a Vec<u8>. You need to interpret four bytes at a time as a 32-bit integer. You write a loop with u32::from_le_bytes. It works. Then you profile it. The copy overhead is killing performance. You try to avoid the copy by slicing, but the borrow checker complains about overlapping borrows. You need to transmute bytes to integers in place, or call a C library that expects a raw pointer, or implement a custom memory allocator that the compiler cannot verify.

You hit a wall. Safe Rust stops you. Not because you are wrong, but because the compiler cannot see the invariants you know are true. The compiler needs proof. You have the proof in your head. You need a way to tell the compiler, "Trust me. I have verified this."

That is what unsafe is for.

The back door to the library

Safe Rust is a library with a strict librarian. The librarian checks every book out. The librarian ensures only one person can edit a book at a time. The librarian stops you from reading a book that has been shredded. The librarian guarantees that you never get a null book. This system is safe. It is fast. It prevents entire classes of bugs at compile time.

unsafe is the back door. You can walk in without checking out. You can grab any book. You can scribble on a book someone else is reading. You can read a book that might be on fire. The librarian won't stop you. The librarian won't even look at you.

The keyword unsafe tells the compiler to drop the safety checks for a specific block of code. It does not make the code unsafe. It makes the code unchecked. The compiler still checks syntax. It still checks types. It still checks that you are not using moved values. But it skips the safety guarantees. No null pointer checks. No aliasing checks. No lifetime checks.

You get superpowers. You can dereference raw pointers. You can call unsafe functions. You can access union fields. You can implement unsafe traits. You can mutate static variables.

With those powers comes a contract. You must uphold the safety invariants. If you break them, you get undefined behavior. The program might crash. It might corrupt data silently. It might look like it works during testing and then explode in production. The responsibility shifts from the compiler to you.

Minimal example

fn main() {
    let x = 42;
    // Create a raw pointer. Safe Rust allows creating raw pointers,
    // because the pointer itself is just an address. It holds no safety guarantees.
    let ptr = &x as *const i32;

    // Dereferencing requires unsafe. The compiler cannot verify the pointer is valid.
    // It doesn't know if x is still alive, if ptr is null, or if ptr is aligned.
    unsafe {
        // SAFETY: We just created this pointer from a valid reference.
        // The value x is alive on the stack. The pointer is non-null and aligned.
        let value = *ptr;
        println!("Value: {}", value);
    }
}

Convention aside: keep unsafe blocks as small as possible. The community calls this the "minimum unsafe surface" rule. Wrap the exact operation that requires unsafe, not the whole function. This makes auditing easier and reduces the chance of accidental misuse.

What happens when you use unsafe

When you write unsafe { ... }, the compiler parses the code inside. It checks that types match. It checks that you are not using moved values. But it skips the safety checks. It does not verify the pointer points to valid memory. It does not check if you are aliasing mutable references.

The burden of proof moves to you. You have to read the code and mentally prove that the operations are valid. If you are wrong, the compiler won't save you. The CPU will execute whatever instructions you generated. If those instructions touch invalid memory, the operating system might kill the process. Or worse, the data corruption spreads silently.

There is a deeper danger. The compiler assumes undefined behavior never happens. This assumption enables optimizations that C compilers cannot always perform. If you have a reference, the compiler knows it is never null. It can skip null checks. If you have a mutable reference, the compiler knows no other reference exists. It can cache values in registers without worrying about data races.

When you use unsafe, you are asking the compiler to trust you with these assumptions. If you lie, the optimizations break. The code doesn't just crash. It behaves in ways that defy logic. A loop might run forever. A variable might change value without assignment. The compiler might delete a guard because it assumes the branch is unreachable. This is why unsafe code requires rigorous mental proof. You are collaborating with the optimizer. You must keep your promises.

Treat the // SAFETY: comment as a legal contract. If you can't defend it in court, rewrite the code.

Realistic example: safe abstraction over unsafe

Most Rust code you write will be safe. The standard library uses unsafe extensively. Vec, String, HashMap all rely on unsafe internally. They provide a safe interface. When you use Vec::push, you are calling safe code that wraps unsafe operations. This is the goal. You use unsafe to build the foundation, then build safe houses on top.

If you expose unsafe directly, you are handing your users a loaded gun. If you wrap it, you are handing them a safe.

/// A simple buffer that demonstrates safe abstraction over unsafe.
///
/// This struct manages a raw pointer but exposes only safe methods.
struct SafeBuffer {
    ptr: *mut u8,
    len: usize,
}

impl SafeBuffer {
    /// Creates a new buffer from a raw pointer.
    ///
    /// # Safety
    /// The caller must ensure the pointer is valid for writes of `len` bytes.
    /// The pointer must be properly aligned for `u8`.
    /// The memory must not be freed while this buffer exists.
    pub unsafe fn from_raw(ptr: *mut u8, len: usize) -> Self {
        // SAFETY: We trust the caller to uphold the safety contract.
        // This is the boundary where unsafe enters the safe world.
        // All subsequent safe methods rely on these invariants.
        Self { ptr, len }
    }

    /// Reads a byte at the given index.
    ///
    /// Returns `None` if the index is out of bounds.
    pub fn get(&self, index: usize) -> Option<u8> {
        // Bounds check in safe code.
        // This prevents out-of-bounds access, which would be undefined behavior.
        if index >= self.len {
            return None;
        }

        // SAFETY: We checked bounds. The pointer is valid because
        // it came from a safe source or was validated at creation.
        // The pointer is aligned for u8. The memory is alive.
        unsafe { Some(*self.ptr.add(index)) }
    }

    /// Writes a byte at the given index.
    ///
    /// Returns `false` if the index is out of bounds.
    pub fn set(&mut self, index: usize, value: u8) -> bool {
        if index >= self.len {
            return false;
        }

        // SAFETY: Bounds checked. Pointer valid and aligned.
        // We have a mutable reference to self, so no aliasing occurs.
        unsafe {
            *self.ptr.add(index) = value;
        }
        true
    }
}

Convention aside: every unsafe block needs a // SAFETY: comment explaining why it is safe. This isn't optional. It is how you communicate with future you and your teammates. The comment should list the invariants that make the block safe. If you can't write the comment, you don't have a proof.

Pitfalls and compiler errors

If you try to dereference a raw pointer outside an unsafe block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This is a good error. It stops you from accidentally using raw pointers.

The real danger is inside the unsafe block. You can write code that compiles, runs, and crashes three days later on a specific input. Common pitfalls include:

  • Dangling pointers. The memory is freed, but the pointer remains. Dereferencing it is undefined behavior.
  • Null pointer dereference. The pointer is null. Dereferencing it is undefined behavior.
  • Mutable aliasing. You have two mutable references to the same data. The compiler assumes this never happens. If it does, the optimizer might generate wrong code.
  • Type confusion. You cast a pointer to the wrong type and access memory with the wrong alignment or size.

Undefined behavior doesn't sleep. It waits for the edge case you didn't test.

When to use unsafe

Use unsafe for FFI when you call C or another language and have to cross out of Rust's safety guarantees to interact with foreign memory layouts and calling conventions.

Use unsafe for performance-critical inner loops where measured profiling shows safe abstractions are the bottleneck and you can prove that manual memory management yields a significant speedup.

Use unsafe when you're implementing a safe abstraction yourself, like a custom allocator, a concurrent data structure, or a wrapper around a complex system call.

Reach for safe Rust when lifetimes are simple and the borrow checker can verify your logic; the unsafe alternative is rarely worth the maintenance cost.

Avoid unsafe for clever optimizations that haven't been profiled; premature optimization with unsafe code creates technical debt that burns later.

Write safe code by default. Reach for unsafe only when you have a gun to your head and a proof in your hand.

Where to go next