RustUnsafe Rust

Unsafe Rust

Rust's safety guarantees are one of its most celebrated features. But those guarantees come from checks the compiler performs at compile time — and there are situations where you know something is safe that the compiler cannot prove. That is where unsafe comes in.

An unsafe block or function does not disable the borrow checker. It grants access to a small, controlled set of additional capabilities that the compiler ordinarily forbids. Everything else — ownership, borrowing, lifetimes — still applies in full.

What unsafe Actually Means

Writing unsafe is a promise from you to the compiler: "I have verified that this code upholds the safety invariants that Rust normally enforces automatically." The compiler trusts you and stops checking those specific invariants — but only within the unsafe block.

Safe Rust code can call into unsafe Rust, and safe abstractions can be built on top of unsafe foundations. The standard library does this constantly — Vec, HashMap, and Arc all use unsafe internally while exposing a completely safe public API.

Note
Incorrect unsafe code can cause undefined behaviour: memory corruption, data races, and crashes that are hard to reproduce. The goal is not to avoid unsafe forever, but to keep its surface area as small as possible and to document every invariant carefully.
The Five Unsafe Superpowers

There are exactly five things you can do inside an unsafe block that you cannot do in safe Rust:

  1. Dereference raw pointers (*const T and *mut T)

  2. Call unsafe functions and methods

  3. Access or modify mutable static variables

  4. Implement unsafe traits

  5. Access fields of unions

Everything else in an unsafe block is still subject to normal Rust rules. You cannot ignore lifetime errors or borrow violations just by wrapping code in unsafe.

Raw Pointers

Raw pointers are written *const T (immutable) and *mut T (mutable). Unlike references, raw pointers:

  • May be null or dangling
  • Do not carry a lifetime
  • Are not checked by the borrow checker
  • Can have multiple mutable pointers to the same location at once

Creating a raw pointer is safe; dereferencing it requires unsafe.

RUST
fn main() {
    let mut value = 5;

    // Create raw pointers from references — safe, no unsafe block needed
    let r1 = &value as *const i32;
    let r2 = &mut value as *mut i32;

    // Dereference — requires unsafe
    unsafe {
        println!("r1 = {}", *r1);
        *r2 = 42;
        println!("r2 = {}", *r2);
    }

    // Create a raw pointer to an arbitrary address (dangerous!)
    let dangling = 0x12345usize as *const i32;
    // Dereferencing 'dangling' would be undefined behaviour — don't do it

    println!("value is now: {}", value);
}
r1 = 5
r2 = 42
value is now: 42
Warning
Raw pointers bypass all of Rust's safety guarantees. Before dereferencing one, you must be absolutely certain it points to valid, properly aligned memory of the correct type and that no data race can occur.
Calling Unsafe Functions

A function marked unsafe fn signals that calling it requires the caller to uphold certain invariants that the compiler cannot check. Calling such a function requires an unsafe block.

RUST
// Declare an unsafe function — the caller must guarantee 'ptr' is valid
unsafe fn dangerous_read(ptr: *const i32) -> i32 {
    *ptr
}

fn main() {
    let x = 99;
    let p = &x as *const i32;

    // SAFETY: p was just created from a valid reference; it cannot be null or dangling.
    let value = unsafe { dangerous_read(p) };
    println!("read: {}", value);
}
read: 99

The standard library's slice::from_raw_parts is a good real-world example of an unsafe function — it creates a slice from a raw pointer and a length, and it is your responsibility to ensure both are valid.

RUST
fn main() {
    let data = vec![1_i32, 2, 3, 4, 5];

    // SAFETY: data.as_ptr() is valid and data.len() is correct.
    let slice = unsafe {
        std::slice::from_raw_parts(data.as_ptr(), data.len())
    };

    println!("slice: {:?}", slice);
}
slice: [1, 2, 3, 4, 5]
Mutable Static Variables

Rust allows global static variables. Immutable statics are perfectly safe — they never change. Mutable statics (static mut) are a different matter: any code can mutate them, creating the risk of data races in a multi-threaded program. Reading or writing a static mut therefore requires unsafe.

RUST
static HELLO: &str = "Hello, world!";  // immutable static — safe to access

static mut COUNTER: u32 = 0;  // mutable static — requires unsafe to touch

fn increment() {
    // SAFETY: This program is single-threaded; no data race is possible.
    unsafe {
        COUNTER += 1;
    }
}

fn main() {
    println!("{}", HELLO);

    increment();
    increment();
    increment();

    // SAFETY: Single-threaded; no concurrent access.
    unsafe {
        println!("COUNTER = {}", COUNTER);
    }
}
Hello, world!
COUNTER = 3
Tip
In multi-threaded code, use std::sync::atomic types orMutex<T> instead of static mut. They provide the same global state without the data-race risk.
Implementing Unsafe Traits

Some traits are marked unsafe trait because implementing them incorrectly can cause undefined behaviour. The two you will encounter most are Send and Sync:

  • Send: safe to transfer ownership of the type to another thread
  • Sync: safe to share a reference to the type across threads (&T is Send)

The compiler implements these automatically for most types. You only need to write unsafe impl when you have a type that the compiler cannot verify but you know is safe to send or share.

RUST
use std::sync::Arc;

// A raw pointer wrapper — the compiler won't auto-impl Send/Sync
struct SafeWrapper(*mut i32);

// SAFETY: We guarantee that SafeWrapper is only ever accessed from one thread
// at a time, making it safe to transfer across thread boundaries.
unsafe impl Send for SafeWrapper {}

fn main() {
    let mut value = 42;
    let wrapper = SafeWrapper(&mut value as *mut i32);

    // Now we can move wrapper into a thread
    let handle = std::thread::spawn(move || {
        // SAFETY: We own wrapper and no other thread has access.
        unsafe { println!("value from thread: {}", *wrapper.0); }
    });

    handle.join().unwrap();
}
value from thread: 42
The SAFETY Comment Convention

Every unsafe block should be accompanied by a // SAFETY: comment that explains why the code is actually safe. This is a community convention, not a compiler requirement, but it is essential for maintainability.

RUST
fn split_at_middle(slice: &[i32]) -> (&[i32], &[i32]) {
    let mid = slice.len() / 2;
    let ptr = slice.as_ptr();

    // SAFETY:
    //   - ptr is non-null because it comes from a valid slice.
    //   - mid <= slice.len(), so ptr.add(mid) is within or one past the end.
    //   - The two sub-slices together cover exactly the original slice.
    //   - The lifetime of both sub-slices is tied to 'slice', preventing use-after-free.
    unsafe {
        (
            std::slice::from_raw_parts(ptr, mid),
            std::slice::from_raw_parts(ptr.add(mid), slice.len() - mid),
        )
    }
}

fn main() {
    let data = [1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_middle(&data);
    println!("left:  {:?}", left);
    println!("right: {:?}", right);
}
left:  [1, 2, 3]
right: [4, 5, 6]
Why the Standard Library Uses Unsafe

It is instructive to know that the standard library's most-used types are all built on unsafe:

  • Vec<T> — manages a heap-allocated buffer with raw pointers internally; methods like push and pop use unsafe to manipulate the pointer and length directly.

  • String — a thin wrapper around Vec<u8> that upholds the UTF-8 invariant; certain conversions use unsafe to avoid redundant validation.

  • HashMap<K, V> — uses unsafe for its hash table implementation to achieve performance while hiding all pointer manipulation behind a safe interface.

  • Arc<T> — uses raw atomic operations (unsafe) to manage the reference count; the public API is completely safe.

  • Box<T> — the fundamental heap allocation primitive; its implementation uses raw pointer allocation and deallocation.

The pattern is always the same: unsafe internals, safe public API. This is the gold standard for writing unsafe code — encapsulate the invariants in a small, well-tested module so that callers never need to think about them.

When You Might Need Unsafe

Scenario

Why unsafe is needed

FFI with C libraries

C has no safety guarantees; all C calls are unsafe

Performance hot paths

Skip bounds checks after you have manually verified bounds

Low-level memory tricks

Reinterpret bytes, work with raw memory layouts

Implementing Send/Sync

Compiler cannot verify thread safety of some types

Custom allocators

Must manipulate raw memory directly

Embedded / OS code

Must write to hardware-mapped memory addresses

Minimising Unsafe Surface Area

The best strategy with unsafe code is to keep it as small and isolated as possible:

  1. Put unsafe code in its own module or function with a narrow, well-defined interface.

  2. Write extensive tests for the unsafe module, including edge cases and boundary conditions.

  3. Document every invariant with a // SAFETY: comment so future maintainers understand the contract.

  4. Consider using crates like bytemuck, zerocopy, or memoffset that provide safe abstractions over common unsafe patterns.

  5. Run cargo miri to check your unsafe code for undefined behaviour under the MIRI interpreter.

Success
Unsafe Rust is not a bug — it is a deliberate, carefully scoped escape hatch. The standard library, the OS ecosystem, and high-performance Rust all rely on it. Used correctly, with small surface areas and clear safety invariants, unsafe code is just as reliable as safe code. The key is discipline: write the// SAFETY: comment before the block, not after.