Rust Unsafe Code: 8 Battle-Tested Patterns That Actually Prevent Memory Bugs

Rust unsafe code done right: learn 8 battle-tested patterns for writing safe `unsafe` blocks, raw pointers, and memory management. Read the guide.

Rust Unsafe Code: 8 Battle-Tested Patterns That Actually Prevent Memory Bugs

Unsafe code in Rust is like a power tool. It gets the job done when nothing else will, but one wrong move and you lose a finger. I have spent years writing Rust, and I have made my share of mistakes. This article is a collection of patterns I now follow. They help me write unsafe code that does not crash or behave like a haunted refrigerator. If you are new to unsafe, do not worry. I will explain everything as if we are sitting in a coffee shop. Bring your laptop.


Let me start with something I learned the hard way. The first time I used unsafe, I wrote a big block of code with raw pointers and no comments. It worked on my machine. Then a colleague ran it on a different CPU and the program turned into a fire hose of garbage memory. That is when I realized that unsafe blocks are not just a badge of courage. They are a promise you make to the compiler. The compiler trusts you not to break its rules. If you break them, the computer does not care about your feelings.

The first pattern is simple: keep unsafe blocks tiny. Do not wrap a whole function in unsafe. Instead, put the smallest possible piece of code inside the block. For example, if you need to call a C function, only call it inside the block. Then you can test that one line in isolation. Here is a real example from a library I wrote to talk to a sensor over serial.

// Bad: big unsafe block
unsafe {
    let data = std::mem::transmute::<_, [u8; 4]>(sensor_raw);
    let status = libc::ioctl(fd, REQUEST, &data as *const _);
    if status < 0 {
        return Err(io::Error::last_os_error());
    }
}
// Good: tiny unsafe block
let data: [u8; 4] = unsafe { std::mem::transmute(sensor_raw) };
let status = unsafe { libc::ioctl(fd, REQUEST, &data as *const _) };
if status < 0 {
    return Err(io::Error::last_os_error());
}

See the difference? In the second version, each unsafe operation is separate. If the transmute is wrong, you know immediately. And you can wrap each unsafe operation inside a safe function. That is the second pattern: build safe wrappers around unsafe primitives.

I once had to implement a ring buffer for audio. The buffer uses raw pointers for speed. Instead of exposing the pointers to users, I made a RingBuffer struct with safe methods like push and pop. Inside those methods, I put small unsafe blocks. The caller never sees unsafe. That is the whole point. Your users should not need to think about safety. You do that work for them.

Here is a skeleton of a safe wrapper for a raw pointer array.

pub struct MyArray<T> {
    ptr: *mut T,
    len: usize,
}

impl<T> MyArray<T> {
    pub fn new(capacity: usize) -> Self {
        let mut v = Vec::with_capacity(capacity);
        let ptr = v.as_mut_ptr();
        std::mem::forget(v); // prevent drop
        MyArray { ptr, len: 0 }
    }

    pub fn push(&mut self, value: T) {
        if self.len >= self.capacity() {
            panic!("no space");
        }
        unsafe {
            self.ptr.add(self.len).write(value);
        }
        self.len += 1;
    }
}

Notice that unsafe only appears in one place, inside push. The rest of the code is pure safe Rust. If I later find a bug, I only have to inspect that one line.

The third pattern is about invariants. An invariant is a condition that must always be true. For example, in a linked list, each node’s next pointer must either be null or point to valid memory. When you use unsafe, you often rely on invariants that the compiler cannot check. You need to enforce them yourself, usually with debug_assert!. In debug builds, these checks will catch mistakes. In release builds, they vanish, so performance does not suffer.

I wrote a custom allocator once. It split memory into blocks. The invariant was that each block’s size must be a multiple of 8 bytes. I added this check at the start of every allocation function.

pub fn allocate(&mut self, size: usize) -> *mut u8 {
    debug_assert!(size % 8 == 0, "size must be aligned to 8");
    // ... unsafe pointer arithmetic ...
}

That debug_assert saved me when I accidentally passed 13 bytes. The debug build panicked instantly. In release, it did not, but by then I had fixed the bug.

The fourth pattern is about using raw pointers correctly. Raw pointers are scary because they can be dangling, null, or misaligned. But you can make them safer by converting them to references as soon as possible, and only inside a small unsafe block. The key rule is: you must guarantee that the pointer points to a valid, aligned, and initialized value for the lifetime of the reference.

I like to use NonNull wrapper when possible. It tells the type system that the pointer is not null. Then I only need to check the other invariants. Here is a pattern I use to wrap a C string that must be valid for the entire function.

fn process_c_string(ptr: *const libc::c_char) {
    let non_null = std::ptr::NonNull::new(ptr).expect("null pointer");
    let cstr = unsafe { std::ffi::CStr::from_ptr(non_null.as_ptr()) };
    // now cstr is safe to use
    println!("{}", cstr.to_string_lossy());
}

The fifth pattern deals with panic safety. In Rust, if a thread panics while holding a mutex, the mutex becomes poisoned. But what about raw pointers? If you are inside an unsafe block and a function panics (like Vec::push can panic when reallocating), you might leave your data in an inconsistent state. The pattern here is to use std::panic::catch_unwind around any code that could panic and leave your unsafe invariants broken.

I wrote a custom allocator that used a global static. If the allocation panicked, the static’s bookkeeping would be wrong. I wrapped the allocation call with catch_unwind and a cleanup routine.

use std::panic::{catch_unwind, AssertUnwindSafe};

fn my_alloc(size: usize) -> *mut u8 {
    let result = catch_unwind(AssertUnwindSafe(|| {
        // actual unsafe allocation
    }));
    match result {
        Ok(ptr) => ptr,
        Err(_) => {
            // clean up any partial state
            std::ptr::null_mut()
        }
    }
}

The sixth pattern is about uninitialized memory. Rust normally requires that every byte of memory be initialized before you read it. But sometimes you need to allocate a buffer and fill it gradually. That is where MaybeUninit comes in. It is a wrapper that tells the compiler: “I know this memory might be garbage, but I promise I will initialize it before I treat it as a real value.”

I used MaybeUninit to build a pool of objects that I reuse. Instead of creating and destroying objects, I keep them in an array. When I need one, I take the next slot, initialize it, and return it. When it is done, I call drop_in_place to clean up and mark the slot as uninitialized again.

use std::mem::MaybeUninit;

struct ObjectPool<T> {
    pool: Vec<MaybeUninit<T>>,
    next: usize,
}

impl<T> ObjectPool<T> {
    fn new(size: usize) -> Self {
        let mut pool = Vec::with_capacity(size);
        pool.resize_with(size, MaybeUninit::uninit);
        ObjectPool { pool, next: 0 }
    }

    fn get(&mut self, init: impl FnOnce() -> T) -> &mut T {
        if self.next >= self.pool.len() {
            panic!("pool exhausted");
        }
        self.pool[self.next] = MaybeUninit::new(init());
        let slot = &mut self.pool[self.next];
        self.next += 1;
        unsafe { slot.assume_init_mut() }
    }
}

The seventh pattern deals with interior mutability. Sometimes you need to mutate a value through a shared reference. That is what UnsafeCell is for. The Rust standard library wraps it with Cell and RefCell for safe types. But when you write your own low-level data structure, you might need to use UnsafeCell directly. The pattern is to always write a safe wrapper around it. Never expose the UnsafeCell to the outside world.

I once built a concurrent stack using a linked list. Each node had a UnsafeCell<*mut Node> as the next pointer. I hid that behind a method that used unsafe inside to get a mutable reference. The caller only saw safe atomic operations.

struct Node {
    value: i32,
    next: UnsafeCell<*mut Node>,
}

impl Node {
    fn set_next(&self, next: *mut Node) {
        unsafe { *self.next.get() = next; }
    }
}

The eighth pattern is about pinned data. Some types require that their memory never moves. For example, Future implementations that are self-referential need to stay at the same address. That is what Pin is for. When you write unsafe code that uses Pin, you must promise that the value will not be moved once it is pinned.

I wrote a simple async executor for embedded systems. The tasks are stored in a static array, pinned at construction. I use Pin::new_unchecked to get a pinned reference, but only after I guarantee the task will never move because the array is static.

struct Task {
    future: Pin<Box<dyn Future<Output = ()>>>,
}

static mut TASKS: [MaybeUninit<Task>; 8] = [MaybeUninit::uninit(); 8];
static mut TASK_COUNT: usize = 0;

fn spawn<F: Future<Output = ()> + 'static>(fut: F) {
    let task = Task { future: Box::pin(fut) };
    unsafe {
        let idx = TASK_COUNT;
        TASKS[idx].write(task);
        TASK_COUNT += 1;
    }
}

I added a comment explaining why we can safely use Pin::new_unchecked here: the tasks are stored in a static that never moves. Without that comment, I would forget the reason in a week.

Writing unsafe code is not about being clever. It is about being meticulous. Every time I open an unsafe block, I ask myself: what are the invariants? How can I test them? How small can I make this block? The eight patterns above have saved me from many headaches. They might save you too.

Start small. Write a safe wrapper. Add debug_assert! checks. Use MaybeUninit and NonNull. Catch panics. Pin when you must. And never trust a raw pointer unless you have earned that trust with careful thought. The compiler will not help you inside unsafe. But you can help yourself by following these patterns. I have used them in production code for years. They work.


// Keep Reading

Similar Articles

Rust's Ouroboros Pattern: Creating Self-Referential Structures Like a Pro
Rust

Rust's Ouroboros Pattern: Creating Self-Referential Structures Like a Pro

The Ouroboros pattern in Rust creates self-referential structures using pinning, unsafe code, and interior mutability. It allows for circular data structures like linked lists and trees with bidirectional references. While powerful, it requires careful handling to prevent memory leaks and maintain safety. Use sparingly and encapsulate unsafe parts in safe abstractions.

Read Article →