What Is the Unsafe Contract and How to Document It

Unsafe Rust allows bypassing memory safety checks for low-level tasks, documented by wrapping code in unsafe blocks with safety justifications.

When the compiler looks away

You're optimizing a hot loop. The borrow checker blocks your pointer arithmetic. You wrap the line in unsafe. The error vanishes. The code runs. You feel like a wizard. Then you refactor a function signature three files away, and the program crashes with a segmentation fault. The compiler didn't warn you. You told it to stop watching. Now you're on your own.

This is the reality of unsafe in Rust. The keyword doesn't mean "this code is dangerous." It means "the compiler cannot verify this code is safe, so you, the human, must verify it." When you use unsafe, you break the safety net. The compiler stops checking specific rules. If you make a mistake, Rust gives you undefined behavior. The program might crash, return garbage, or silently corrupt data. There is no runtime panic to save you.

The unsafe contract is the documentation that proves your code is safe despite the missing checks. It tells the next developer (or future you) exactly what conditions must hold for the code to work. Without a clear contract, unsafe is a time bomb. With a solid contract, unsafe is a tool that lets you build high-performance systems and talk to C libraries while keeping the rest of your codebase safe.

The contract is a proof, not a promise

Think of unsafe like a manual override on a nuclear reactor. The automated safety systems are turned off. You can push the button, but if you push it at the wrong time, the reactor melts down. The contract is the checklist written on the wall next to the button. It lists every condition that must be true before you touch the override.

If the checklist says "Check pressure," and you write "Pressure is okay," that's useless. You need "Pressure is between 100 and 200 psi." The contract must be specific enough that someone can verify it. Vague comments like "This is safe because I checked" provide zero value. They are worse than no comment because they create a false sense of security.

Rust has two types of unsafe contracts. Caller safety puts the burden on the person calling the function. Implementer safety puts the burden on the person writing the function. You document both, but they look different.

Minimal example: unsafe function

An unsafe fn tells the caller, "I can't check your inputs. You must promise they are valid." You document this with a /// # Safety section in the doc comment. This section lists the invariants the caller must uphold.

/// Reads a value from a raw pointer.
///
/// # Safety
/// The caller must ensure `ptr` meets these conditions:
/// 1. `ptr` is non-null.
/// 2. `ptr` is properly aligned for an `i32`.
/// 3. `ptr` points to an initialized `i32` value.
/// 4. No mutable references exist to the memory `ptr` points to.
pub unsafe fn read_i32(ptr: *const i32) -> i32 {
    // SAFETY:
    // 1. `ptr` is non-null.
    // 2. `ptr` is aligned for `i32`.
    // 3. `ptr` points to initialized memory.
    // 4. No aliasing mutable references exist.
    unsafe { *ptr }
}

The doc comment is for the caller. It explains what the caller must do. The // SAFETY: comment inside the function is for the implementer. It lists the invariants that hold at that exact line of code. The inline comment proves the code is safe based on the caller's promise.

Treat the // SAFETY: comment as a proof. If you can't write the invariants, you don't have a proof.

Caller safety vs implementer safety

The distinction between caller safety and implementer safety is the core of the contract. When you write an unsafe fn, the function itself is safe to implement. The danger comes from the caller passing bad data. The /// # Safety doc comment protects the caller by telling them what "bad data" looks like.

When you use an unsafe {} block inside a safe function, the burden shifts to you. You are the implementer. You must verify the invariants before entering the block. The // SAFETY: comment documents your verification. The function remains safe for callers because you handle the risk internally.

/// Returns the value at `index` if it is within bounds.
///
/// This function is safe to call. It checks bounds before accessing memory.
pub fn get_checked(data: &[i32], index: usize) -> Option<i32> {
    if index >= data.len() {
        return None;
    }

    // SAFETY:
    // 1. `data` is a valid slice, so its pointer is non-null and aligned.
    // 2. `index` is less than `data.len()`, so the pointer arithmetic stays in bounds.
    // 3. The slice guarantees the memory is initialized.
    let ptr = data.as_ptr().add(index);
    unsafe { Some(*ptr) }
}

Here, the function is safe. The caller doesn't need to know about pointers. You checked the bounds. You verified the invariants. The unsafe block is isolated. The contract is internal.

Keep unsafe blocks as small as possible. The community calls this the "minimum unsafe surface" rule. A smaller block is easier to verify. It reduces the chance of a mistake. If your unsafe block spans twenty lines, you're probably doing too much. Break it down. Verify each step.

Realistic example: wrapping FFI

Foreign Function Interface (FFI) is the most common use of unsafe. You call C code, and C doesn't know about Rust's safety rules. You must wrap the C calls in Rust functions that enforce the contract.

use std::ffi::CStr;
use std::os::raw::c_char;

extern "C" {
    /// Returns a pointer to a static C string.
    /// May return null if no message is available.
    fn get_system_message() -> *const c_char;
}

/// Returns the system message as a Rust string slice.
///
/// This function is safe. It handles null checks and validates the C string.
pub fn get_message_safe() -> Option<&'static str> {
    // SAFETY:
    // 1. `get_system_message` is a C function declared in the extern block.
    // 2. The C library guarantees the returned pointer is either null
    //    or points to a valid, null-terminated static string.
    let ptr = unsafe { get_system_message() };

    if ptr.is_null() {
        return None;
    }

    // SAFETY:
    // 1. `ptr` is non-null (checked above).
    // 2. `ptr` points to a valid null-terminated C string.
    // 3. The string is static and lives for the program duration.
    unsafe {
        CStr::from_ptr(ptr).to_str().ok()
    }
}

The extern "C" block is inherently unsafe. You can't call those functions without unsafe. The wrapper get_message_safe takes the risk and neutralizes it. It checks for null. It validates the string encoding. It returns an Option. The caller gets a safe API. The unsafe is hidden behind a verified contract.

Convention aside: when you call Rc::clone(&data) versus data.clone(), both work. The explicit form is preferred because data.clone() looks like a deep clone but isn't. Similarly, when you write unsafe { ... }, the convention is to put the // SAFETY: comment immediately before the block. Don't bury it three lines up. Make the proof visible.

Pitfalls and compiler errors

The compiler still helps you, even with unsafe. It enforces that you use unsafe blocks for dangerous operations. If you try to dereference a raw pointer without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This error is your friend. It catches accidental unsafe code.

fn bad_example(ptr: *const i32) {
    // Error: E0133 dereference of raw pointer requires unsafe block or function
    let value = *ptr;
}

The compiler also catches type mismatches and moved values. unsafe doesn't disable the borrow checker for everything. It only disables checks related to memory safety that the compiler can't verify. You still get E0382 (use of moved value) and E0308 (mismatched types). The borrow checker still works for references and ownership. unsafe only opens the door for raw pointers, mutable statics, and other low-level operations.

Common pitfalls include:

  • Vague invariants. Writing "This is safe" in a // SAFETY: comment is useless. List the specific conditions. Null checks, alignment, initialization, aliasing. Be precise.
  • Forgetting alignment. Pointers must be aligned to the size of the type. Dereferencing a misaligned pointer is undefined behavior, even if the memory is valid. Use ptr::align_offset if you need to adjust alignment.
  • Dangling pointers. A pointer must point to valid memory. If the memory is freed, the pointer dangles. Dereferencing a dangling pointer is undefined behavior. Track lifetimes manually when using raw pointers.
  • Aliasing violations. If you create a mutable reference from a raw pointer, no other references can exist to that memory. If you create an immutable reference, no mutable references can exist. Violating this is undefined behavior.

A vague SAFETY comment is a time bomb. Be specific, or don't write it.

When to use unsafe

Use unsafe for FFI when you call C or another language and must cross the boundary where Rust's guarantees end. Wrap the FFI calls in safe functions that verify inputs and outputs.

Use unsafe for performance-critical inner loops when profiling proves safe abstractions are the bottleneck and you can prove the invariants manually. Measure first. Optimize only when necessary.

Use unsafe when implementing a safe abstraction like a custom allocator, a lock-free data structure, or a wrapper around system calls. The goal is to hide the unsafe behind a safe API.

Reach for RefCell or Mutex when you need interior mutability and the borrow checker is blocking you. The overhead is usually worth the safety. Raw pointers are rarely the right tool for runtime borrowing.

Reach for Option or Result when you're handling potential absence of data. Raw pointers are the wrong tool for "maybe null." Use Option<&T> or Option<Box<T>> instead.

Reach for slices and references when lifetimes are simple. The unsafe alternative is rarely worth it. The borrow checker exists to save you from subtle bugs. Trust it.

The goal of unsafe is to disappear. Wrap it in a safe function and let the rest of the codebase sleep soundly.

Where to go next