How to Document Unsafe Code in Rust

Document unsafe Rust code by adding a SAFETY comment explaining the specific invariants that make the operation safe.

When the compiler stops checking

You just wrapped a C library call in an unsafe block. The compiler stopped complaining. Your code compiles. You feel a rush of victory. Then your teammate reviews the PR and asks, "How do we know this pointer isn't dangling?" You stare at the screen. The unsafe block is there, but the reasoning isn't. The code works today, but next month, someone changes the allocation logic, the pointer becomes invalid, and your program segfaults in production. The unsafe keyword tells the compiler to trust you. It does not tell the next human reader why that trust is justified. Without documentation, unsafe code is a landmine. You planted it, you know where it is, but the next person walking through the codebase has no map.

The contract behind the keyword

Rust's ownership system prevents data races and memory errors by checking your code at compile time. The compiler tracks every value. It knows when data is created, moved, and dropped. It enforces rules that guarantee memory safety. The unsafe keyword tells the compiler to stop checking. You are taking responsibility. The compiler assumes you have verified the safety conditions manually. This is powerful. It lets you do things the safe language cannot. It also removes the safety net. If you make a mistake, the compiler will not catch it. The program might crash, corrupt memory, or introduce a security vulnerability.

Documentation is the only record of your verification. A // SAFETY: comment lists the invariants that must hold true. Invariants are conditions that never change while the code runs. Examples include pointer validity, alignment, and ownership. When you document these invariants, you create a contract. Anyone reading the code can check the contract against the implementation. If the contract holds, the code is safe. If the contract breaks, the code is broken.

Think of unsafe like a "Bypass Safety Interlock" button on industrial machinery. You can press it, but you have to log why it is safe to bypass. If you just press it without a log, the next operator might press it when the machine is actually broken, and someone gets hurt. The // SAFETY: comment is that log. It proves you checked the machine before you bypassed the guard.

Minimal example: proving a pointer read

Every unsafe block needs a comment immediately before it. The comment lists the invariants as a numbered list. This format makes it easy to verify each condition.

/// Reads an i32 from a raw pointer.
///
/// # Safety
/// The caller must ensure `ptr` is valid for reads and properly aligned.
fn read_from_raw(ptr: *const i32) -> i32 {
    // SAFETY:
    // 1. `ptr` is valid for reads of size `std::mem::size_of::<i32>()`.
    // 2. `ptr` is properly aligned for `i32`.
    unsafe {
        // The dereference is safe because the invariants above hold.
        *ptr
    }
}

The /// # Safety doc comment warns the caller. It tells them what they must guarantee before calling the function. The // SAFETY: comment inside the function proves the implementation respects those guarantees. The numbered list breaks down the proof. Invariant 1 checks validity. Invariant 2 checks alignment. If either condition fails, the code is undefined behavior. The comment makes this explicit.

Walkthrough: how a maintainer reads your proof

When a maintainer modifies your code, they start by reading the // SAFETY: comment. They build a mental model of the requirements. They look for ptr is valid and ptr is aligned. Then they scan the code to see if those conditions are met. If the maintainer changes the function to accept a pointer from a different source, they check if the new source still satisfies the invariants. If the new source can return a null pointer, the maintainer sees a conflict with invariant 1. They fix the code or update the comment. The comment acts as a checklist. It catches errors before they become bugs.

Without the comment, the maintainer has to guess. They might assume the pointer is always valid. They might introduce a change that breaks that assumption. The compiler won't warn them. The bug slips through. The comment forces the maintainer to confront the safety requirements. It slows them down just enough to prevent mistakes.

Realistic example: a safe abstraction over raw pointers

You often use unsafe to build safe abstractions. A Vec is a prime example. The public API is safe. The internal implementation uses raw pointers for performance and flexibility. The // SAFETY: comments bridge the gap between the raw operations and the safe guarantees.

/// A minimal vector-like structure to demonstrate unsafe invariants.
struct MyVec {
    data: *mut i32,
    len: usize,
    cap: usize,
}

impl MyVec {
    /// Creates a new MyVec with the given capacity.
    fn new(cap: usize) -> Self {
        // SAFETY:
        // 1. `std::alloc::alloc` returns a valid, aligned pointer if successful.
        // 2. We check for null to handle allocation failure.
        let ptr = unsafe { std::alloc::alloc(std::alloc::Layout::array::<i32>(cap).unwrap()) };
        if ptr.is_null() {
            panic!("Allocation failed");
        }
        Self {
            data: ptr,
            len: 0,
            cap,
        }
    }

    /// Pushes a value onto the vector.
    ///
    /// # Safety
    /// The caller must ensure `self.cap > self.len`.
    pub unsafe fn push(&mut self, value: i32) {
        // SAFETY:
        // 1. `self.data` is valid for writes up to `self.cap`.
        // 2. `self.len < self.cap` is guaranteed by the caller.
        // 3. `self.data.add(self.len)` computes a pointer within bounds.
        // 4. `write` does not drop the existing value, which is safe here
        //    because the slot is uninitialized or we are overwriting.
        unsafe {
            self.data.add(self.len).write(value);
        }
        self.len += 1;
    }
}

The push function uses add and write. Both are unsafe operations. add computes a new pointer. write places a value at that pointer. The // SAFETY: comment lists four invariants. Invariant 1 ensures the base pointer is valid. Invariant 2 ensures we are not overflowing the capacity. Invariant 3 ensures the computed pointer stays within bounds. Invariant 4 explains why write is used instead of dereference assignment. write avoids dropping the old value, which matters for uninitialized memory. The comment covers every unsafe operation in the block. A reviewer can check each invariant against the code. If the reviewer sees that self.len could equal self.cap, they flag the violation. The comment makes the review concrete.

Pitfalls and compiler errors

If you try to dereference a raw pointer without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This error forces you to acknowledge the risk. The trap comes after you add unsafe. You might write a comment like // SAFETY: This is safe. That comment is useless. It provides no proof. A future maintainer cannot verify it. You must list specific invariants.

Another trap is the "zombie comment." You refactor the code to use a different pointer, but you forget to update the // SAFETY: block. The comment now claims the old pointer is valid, while the code uses a new one. The mismatch hides bugs. Treat the comment as part of the contract. If the code changes, the comment changes too.

Vague invariants are also dangerous. Writing // SAFETY: ptr is valid is often insufficient. "Valid" means different things for reads, writes, and alignment. Be precise. Use valid for reads, valid for writes, aligned, non-null. Precision prevents misunderstandings.

The compiler cannot check your comments. It treats them as text. You can lie in a // SAFETY: comment. The code will compile. The lie will only surface when the code crashes. This is why the community treats // SAFETY: comments as proofs. If you cannot write a convincing proof, you should not use unsafe. Refactor the code to use safe abstractions instead.

Convention asides

The community convention is to place // SAFETY: immediately before the unsafe block. This keeps the proof close to the dangerous code. Inside the comment, use a numbered list for invariants. This makes it easy to check each condition one by one. Another convention is the "minimum unsafe surface" rule. Keep the unsafe block as small as possible. Only wrap the exact operation that requires it. If you can move a variable declaration outside the block, do it. This reduces the chance that accidental unsafe behavior sneaks in.

When documenting public functions, use /// # Safety in the doc comment. This appears in the generated documentation. It warns users about their responsibilities. The // SAFETY: comment is for the implementation. The /// # Safety comment is for the API. Both are needed.

Decision matrix

Use // SAFETY: comments for every unsafe block to document the invariants that justify the operation. Use /// # Safety in doc comments for public functions to warn callers about their responsibilities. Use unsafe blocks for FFI when you call C or another language and have to cross out of Rust's safety. Use unsafe for performance-critical inner loops where measured profiling shows safe abstractions are the bottleneck, even there, isolate it in a small helper. Use unsafe when you're implementing a safe abstraction yourself, like a Vec, a linked list, or an allocator. Reach for safe abstractions when lifetimes are simple; the unsafe alternative is rarely worth it.

Treat the SAFETY comment as a proof. If you can't write it, you don't have one.

Where to go next