Skip to content
Codeloom
Rust

Rust Unsafe Code Guidelines: When and How to Use unsafe

Learn when unsafe Rust is justified, what the five unsafe superpowers are, and how to write unsafe code that maintains Rust's safety guarantees.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • The five things unsafe unlocks
  • When unsafe is actually justified
  • How to minimize and encapsulate unsafe code
  • Common patterns and pitfalls

Prerequisites

  • Solid Rust knowledge
  • Understanding of ownership and references

The unsafe keyword does not turn off Rust’s safety guarantees. It tells the compiler “I am taking responsibility for upholding these guarantees myself.” This is a serious commitment. Most Rust programs never need unsafe, but when they do, understanding the rules is critical.

What unsafe Unlocks

An unsafe block lets you do exactly five things that safe Rust forbids:

  1. Dereference a raw pointer (*const T or *mut T)
  2. Call an unsafe function or method
  3. Access or modify a mutable static variable
  4. Implement an unsafe trait
  5. Access fields of a union

Nothing else changes. The borrow checker still runs. Type checking still applies. You are just allowed to do these five specific operations.

fn main() {
    // 1. Dereference a raw pointer
    let x = 42;
    let ptr = &x as *const i32;
    unsafe {
        println!("Value: {}", *ptr);
    }

    // 2. Call an unsafe function
    unsafe {
        let layout = std::alloc::Layout::new::<u64>();
        let ptr = std::alloc::alloc(layout);
        if !ptr.is_null() {
            *(ptr as *mut u64) = 12345;
            println!("Allocated: {}", *(ptr as *const u64));
            std::alloc::dealloc(ptr, layout);
        }
    }

    // 3. Mutable static variable
    static mut COUNTER: u32 = 0;
    unsafe {
        COUNTER += 1;
        println!("Counter: {COUNTER}");
    }
}

When unsafe Is Justified

There are legitimate reasons to use unsafe:

FFI: Calling C Libraries

The most common use case. C functions have no safety guarantees, so calling them requires unsafe:

// Declare the C function signature
extern "C" {
    fn abs(input: i32) -> i32;
    fn strlen(s: *const std::ffi::c_char) -> usize;
}

fn safe_abs(n: i32) -> i32 {
    // Wrap unsafe in a safe API
    unsafe { abs(n) }
}

fn safe_strlen(s: &str) -> usize {
    let c_string = std::ffi::CString::new(s).expect("null byte in string");
    unsafe { strlen(c_string.as_ptr()) }
}

fn main() {
    println!("abs(-5) = {}", safe_abs(-5));
    println!("strlen(\"hello\") = {}", safe_strlen("hello"));
}

Performance-Critical Code

When you have proven (via profiling) that a bounds check is the bottleneck:

fn sum_unchecked(slice: &[i32]) -> i32 {
    let mut total = 0;
    // SAFETY: we iterate from 0..len, so all indices are valid.
    for i in 0..slice.len() {
        total += unsafe { *slice.get_unchecked(i) };
    }
    total
}

fn main() {
    let data = vec![1, 2, 3, 4, 5];
    println!("Sum: {}", sum_unchecked(&data));
}

In practice, the compiler often eliminates bounds checks on its own. Profile first.

Building Safe Abstractions Over Unsafe Foundations

Many standard library types use unsafe internally: Vec, String, HashMap, Mutex. They expose safe APIs while managing raw memory or OS calls internally.

struct FixedBuffer {
    ptr: *mut u8,
    len: usize,
    capacity: usize,
}

impl FixedBuffer {
    fn new(capacity: usize) -> Self {
        let layout = std::alloc::Layout::array::<u8>(capacity).unwrap();
        let ptr = unsafe { std::alloc::alloc(layout) };
        if ptr.is_null() {
            std::alloc::handle_alloc_error(layout);
        }
        FixedBuffer { ptr, len: 0, capacity }
    }

    fn push(&mut self, byte: u8) -> bool {
        if self.len >= self.capacity {
            return false;
        }
        // SAFETY: len < capacity, so ptr.add(len) is within the allocation
        unsafe {
            self.ptr.add(self.len).write(byte);
        }
        self.len += 1;
        true
    }

    fn as_slice(&self) -> &[u8] {
        // SAFETY: ptr is valid for len bytes, and we maintain the invariant
        // that all bytes 0..len are initialized
        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
    }
}

impl Drop for FixedBuffer {
    fn drop(&mut self) {
        let layout = std::alloc::Layout::array::<u8>(self.capacity).unwrap();
        unsafe { std::alloc::dealloc(self.ptr, layout) };
    }
}

fn main() {
    let mut buf = FixedBuffer::new(16);
    buf.push(b'H');
    buf.push(b'i');
    println!("{}", std::str::from_utf8(buf.as_slice()).unwrap());
}

Guidelines for Writing Unsafe Code

1. Minimize the Unsafe Surface

Keep unsafe blocks as small as possible. Do not put an entire function body inside unsafe:

// Bad: too much inside unsafe
fn process(data: &[u8]) -> u8 {
    unsafe {
        let first = *data.get_unchecked(0);
        let result = first.wrapping_add(1); // This line is safe
        let doubled = result * 2;           // This line is safe too
        doubled
    }
}

// Good: only the unsafe operation is in the block
fn process_better(data: &[u8]) -> u8 {
    // SAFETY: caller guarantees data is non-empty
    let first = unsafe { *data.get_unchecked(0) };
    let result = first.wrapping_add(1);
    result * 2
}

2. Document Safety Invariants

Every unsafe block should have a // SAFETY: comment explaining why it is sound:

fn get_first(slice: &[i32]) -> Option<i32> {
    if slice.is_empty() {
        return None;
    }
    // SAFETY: We just checked that the slice is non-empty,
    // so index 0 is valid.
    Some(unsafe { *slice.get_unchecked(0) })
}

3. Encapsulate Behind Safe APIs

The unsafe code should be an implementation detail. The public API should be safe:

pub struct BitVec {
    data: Vec<u8>,
    len: usize,
}

impl BitVec {
    pub fn new() -> Self {
        BitVec { data: Vec::new(), len: 0 }
    }

    pub fn get(&self, index: usize) -> Option<bool> {
        if index >= self.len {
            return None;
        }
        let byte = self.data[index / 8];
        let bit = (byte >> (index % 8)) & 1;
        Some(bit == 1)
    }

    pub fn push(&mut self, value: bool) {
        if self.len % 8 == 0 {
            self.data.push(0);
        }
        if value {
            let byte_index = self.len / 8;
            let bit_index = self.len % 8;
            self.data[byte_index] |= 1 << bit_index;
        }
        self.len += 1;
    }
}

4. Use Miri for Testing

Miri is a Rust interpreter that detects undefined behavior in unsafe code:

// Run with: cargo +nightly miri test

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_fixed_buffer() {
        let mut buf = FixedBuffer::new(4);
        assert!(buf.push(1));
        assert!(buf.push(2));
        assert_eq!(buf.as_slice(), &[1, 2]);
    }
}

Common Pitfalls

Creating invalid references. A &T must always point to valid, aligned, initialized data. Creating a reference to uninitialized memory is instant undefined behavior, even if you never read through it.

Aliasing mutable references. If you create two &mut T pointing to the same memory, that is undefined behavior even if you only write through one of them. The compiler assumes &mut T is unique.

Forgetting Drop. If you allocate memory with alloc, you must deallocate it. If you create a raw pointer from a Box, you must reconstruct the Box to free it:

fn main() {
    let boxed = Box::new(42);
    let ptr = Box::into_raw(boxed);

    // ... use ptr ...

    // SAFETY: ptr came from Box::into_raw and has not been freed
    let _boxed = unsafe { Box::from_raw(ptr) };
    // Memory is freed when _boxed is dropped
}

Assuming layout. Do not assume struct fields are laid out in declaration order. Use #[repr(C)] if you need a specific layout for FFI.

Wrapping Up

Unsafe Rust is a tool, not a shortcut. Use it for FFI, performance-critical hot paths you have profiled, and building safe abstractions over low-level operations. Keep unsafe blocks small, document every safety invariant, encapsulate behind safe APIs, and test with Miri. If you find yourself reaching for unsafe to work around the borrow checker, step back and redesign. There is almost always a safe solution.