Skip to content
AgileLogics
8 min read

Unsafe Rust

Prerequisites: Procedural macros

The borrow checker is conservative: it rejects some programs that are actually correct. unsafe is how you tell the compiler "I have checked this myself". It does not turn off the borrow checker or type system; it unlocks a small set of extra operations, and makes you responsible for upholding the rules they could break.

The five unsafe superpowers

Inside an unsafe block you may:

  1. Dereference a raw pointer (*const T, *mut T).
  2. Call an unsafe fn (including foreign functions).
  3. Read or write a static mut.
  4. Implement an unsafe trait (such as Send or Sync).
  5. Read fields of a union.

Everything else — borrowing rules, bounds checks on normal indexing, type checking — still applies.

Raw pointers

Creating a raw pointer is safe; dereferencing one is not, because the compiler can no longer prove it points to valid, live, properly aligned memory.

fn main() {
    let mut x = 10;
    let p = &raw mut x; // a *mut i32; creating it is safe

    // SAFETY: `p` points to `x`, which is live, aligned and not otherwise
    // borrowed while we use the pointer.
    unsafe {
        *p += 5;
        println!("{}", *p);
    }
}

Expected output:

15

&raw const x and &raw mut x create pointers without first creating a reference. Raw pointers can be null, dangle, alias each other and ignore lifetimes — that freedom is why reading through them is unsafe.

Wrapping unsafe in a safe API

The idiomatic use of unsafe is a small block inside a function whose safe signature cannot be misused. The standard library's split_at_mut is the textbook case: two mutable borrows of one slice are rejected by the borrow checker even though they don't overlap.

fn split_at_mut<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    let len = slice.len();
    assert!(mid <= len, "mid out of bounds");
    let ptr = slice.as_mut_ptr();

    // SAFETY: `ptr` is valid for `len` elements. The two ranges
    // [0, mid) and [mid, len) do not overlap and both lie inside the
    // original slice, whose unique borrow we hold for the output lifetime.
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut v = [1, 2, 3, 4, 5];
    let (left, right) = split_at_mut(&mut v, 2);
    left[0] = 10;
    right[0] = 30;
    println!("{v:?}");
}

Expected output:

[10, 2, 30, 4, 5]

The assert! is what makes this function safe: without it, a caller could pass mid > len and get memory outside the slice from safe code.

unsafe fn and SAFETY comments

Mark a function unsafe fn when callers must uphold a condition that the function can't check. Document it under a # Safety heading. In the 2024 edition the body of an unsafe fn is not automatically an unsafe block; unsafe operations inside it need their own unsafe { }, or the unsafe_op_in_unsafe_fn lint warns.

/// Returns the element at `index` without bounds checking.
///
/// # Safety
///
/// `index` must be less than `values.len()`.
unsafe fn get_unchecked_i32(values: &[i32], index: usize) -> i32 {
    // SAFETY: the caller guarantees `index < values.len()`.
    unsafe { *values.get_unchecked(index) }
}

fn main() {
    let v = [4, 8, 15];
    // SAFETY: 2 < v.len() == 3.
    let x = unsafe { get_unchecked_i32(&v, 2) };
    println!("{x}");
}

Expected output:

15

The convention is strict: every unsafe block gets a // SAFETY: comment explaining why the operation is sound here, and every unsafe fn gets a # Safety section. Clippy's undocumented_unsafe_blocks lint can enforce the first.

Checking your work with Miri

Undefined behaviour often "works" in testing and then fails with a new compiler version or optimisation level. Miri is an interpreter that runs your tests and detects many kinds of UB: out-of-bounds access, use after free, invalid alignment, data races and aliasing violations.

rustup +nightly component add miri
cargo +nightly miri test

Run it in CI for any crate that contains unsafe.

Common mistakes

  • Writing the obvious split_at_mut with two reborrows:
fn split_at_mut<T>(s: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
    (&mut s[..mid], &mut s[mid..])
}
error[E0499]: cannot borrow `*s` as mutable more than once at a time

Before writing unsafe, check whether std already has what you need — it does here (slice::split_at_mut).

  • Taking references to a static mut. The 2024 edition rejects it:
error: creating a shared reference to mutable static

Use an atomic, a Mutex, or OnceLock instead.

  • Treating unsafe as "the compiler trusts me, so it's fine". A single violated invariant is undefined behaviour anywhere in the program, not just in that block.
  • Large unsafe blocks. Keep them to the few lines that need it so the reasoning in the SAFETY comment stays checkable.
  • Unsafe code that relies on a safe function behaving correctly (for example, a user's Ord impl being consistent). Safe code is allowed to be wrong; unsafe code must stay sound anyway.

Practice

Write a safe function swap_first_last(v: &mut [i32]) that swaps the first and last elements using raw pointers and std::ptr::swap. It must do nothing for slices shorter than two elements. Add a SAFETY comment.

Show solution
fn swap_first_last(v: &mut [i32]) {
    if v.len() < 2 {
        return;
    }
    let ptr = v.as_mut_ptr();
    // SAFETY: len >= 2, so index 0 and index len - 1 are both in bounds
    // and distinct. We hold the only (mutable) borrow of `v`.
    unsafe {
        std::ptr::swap(ptr, ptr.add(v.len() - 1));
    }
}

fn main() {
    let mut a = [1, 2, 3, 4];
    swap_first_last(&mut a);
    println!("{a:?}");

    let mut b = [7];
    swap_first_last(&mut b);
    println!("{b:?}");
}

This prints [4, 2, 3, 1] and [7]. In real code you would call the safe v.swap(0, v.len() - 1); the exercise shows how the guard makes the unsafe call sound.