In the previous tutorial, we explored WebAssembly. Now we learn unsafe Rust — what it means, when you need it, and how to use it without breaking Rust’s safety guarantees.

Rust’s safety system prevents bugs at compile time. But some things are impossible to verify at compile time. Raw pointer operations, FFI calls, and certain data structures need to bypass the borrow checker. That’s what unsafe is for.

Important: unsafe doesn’t mean “dangerous” or “bad.” It means “the programmer is responsible for correctness here, not the compiler.” Used correctly, unsafe code is just as safe as safe code.

What Does Unsafe Allow?

The unsafe keyword unlocks five superpowers:

  1. Dereference raw pointers — access memory through *const T and *mut T
  2. Call unsafe functions — functions marked unsafe fn
  3. Access mutable static variables — global mutable state
  4. Implement unsafe traits — traits that have safety invariants
  5. Access union fields — C-compatible union types

Everything else in Rust works the same inside unsafe. You still have the borrow checker, type system, and lifetime checks.

Raw Pointers

Raw pointers are like references but without Rust’s safety guarantees:

fn raw_pointer_basics() {
    let x = 42;
    let r1 = &x as *const i32;  // immutable raw pointer
    let mut y = 10;
    let r2 = &mut y as *mut i32; // mutable raw pointer

    // Creating raw pointers is safe.
    // Dereferencing them is unsafe.
    unsafe {
        println!("r1 points to: {}", *r1);
        println!("r2 points to: {}", *r2);
        *r2 = 20;
        println!("r2 after write: {}", *r2);
    }
}

Creating a raw pointer is safe. You can create as many as you want. But dereferencing (reading or writing through the pointer) is unsafe because:

  • The pointer might be null
  • The pointer might point to freed memory
  • The pointer might be misaligned
  • Multiple mutable raw pointers can exist for the same memory

Pointer Arithmetic

You can do pointer math like in C:

fn pointer_arithmetic() -> Vec<i32> {
    let data = vec![10, 20, 30, 40, 50];
    let ptr = data.as_ptr();
    let mut results = Vec::new();

    unsafe {
        for i in 0..data.len() {
            results.push(*ptr.add(i));
        }
    }

    results
}

ptr.add(i) advances the pointer by i elements (not bytes). This is the same as ptr + i in C, but type-safe — it knows the element size.

Unsafe Functions

Functions that have safety requirements the compiler can’t check are marked unsafe fn:

/// Splits a mutable slice at the given index.
/// The caller must ensure mid <= slice.len().
unsafe fn split_at_unchecked(
    slice: &mut [i32],
    mid: usize,
) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();

    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

This creates two mutable references to different parts of the same slice. Normally, Rust forbids two mutable references to the same data. But here we know they don’t overlap, so it’s safe. The compiler can’t prove this, so we use unsafe.

The Safe Wrapper Pattern

The most important pattern in unsafe Rust: wrap unsafe code in a safe API:

fn split_at_safe(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    assert!(mid <= slice.len(), "mid out of bounds");
    unsafe { split_at_unchecked(slice, mid) }
}

The safe wrapper validates the precondition (mid <= len). If the precondition holds, the unsafe code is correct. Users of split_at_safe never need to write unsafe themselves.

This is how the standard library works. Vec, HashMap, and String all use unsafe internally but expose safe APIs.

Building an Unsafe Data Structure

Let’s build a simple vector from scratch to understand how Vec works:

struct SimpleVec<T> {
    ptr: *mut T,
    len: usize,
    capacity: usize,
}

impl<T> SimpleVec<T> {
    fn new() -> Self {
        Self {
            ptr: std::ptr::null_mut(),
            len: 0,
            capacity: 0,
        }
    }

    fn push(&mut self, value: T) {
        if self.len >= self.capacity {
            self.grow();
        }
        unsafe {
            self.ptr.add(self.len).write(value);
        }
        self.len += 1;
    }

    fn pop(&mut self) -> Option<T> {
        if self.len == 0 {
            return None;
        }
        self.len -= 1;
        unsafe { Some(self.ptr.add(self.len).read()) }
    }

    fn get(&self, index: usize) -> Option<&T> {
        if index >= self.len {
            return None;
        }
        unsafe { Some(&*self.ptr.add(index)) }
    }
}

The key operations:

  • ptr.add(n).write(value) — writes a value at position n without dropping the old value
  • ptr.add(n).read() — reads a value at position n (moves it out)
  • &*ptr.add(n) — creates a reference to the value at position n

Memory Allocation

Growing the vector needs manual memory management:

fn grow(&mut self) {
    let new_capacity = if self.capacity == 0 { 4 } else { self.capacity * 2 };
    let new_layout = std::alloc::Layout::array::<T>(new_capacity).unwrap();

    let new_ptr = if self.capacity == 0 {
        unsafe { std::alloc::alloc(new_layout) as *mut T }
    } else {
        let old_layout = std::alloc::Layout::array::<T>(self.capacity).unwrap();
        unsafe {
            std::alloc::realloc(
                self.ptr as *mut u8,
                old_layout,
                new_layout.size(),
            ) as *mut T
        }
    };

    if new_ptr.is_null() {
        std::alloc::handle_alloc_error(new_layout);
    }

    self.ptr = new_ptr;
    self.capacity = new_capacity;
}

Drop Implementation

When the vector is dropped, we must free memory and drop all elements:

impl<T> Drop for SimpleVec<T> {
    fn drop(&mut self) {
        if self.capacity > 0 {
            for i in 0..self.len {
                unsafe { self.ptr.add(i).drop_in_place(); }
            }
            let layout = std::alloc::Layout::array::<T>(self.capacity).unwrap();
            unsafe { std::alloc::dealloc(self.ptr as *mut u8, layout); }
        }
    }
}

drop_in_place() runs the destructor for each element. Then dealloc() frees the memory block. If we forget either step, we leak resources.

This is why the standard library’s Vec uses unsafe internally — it manages raw memory. But the safe API prevents users from causing memory errors.

Unsafe Traits

An unsafe trait has invariants that the implementor must guarantee:

/// A type that can be safely created as all-zeros.
unsafe trait Zeroable {
    fn zeroed() -> Self;
}

unsafe impl Zeroable for i32 {
    fn zeroed() -> Self { 0 }
}

unsafe impl Zeroable for f64 {
    fn zeroed() -> Self { 0.0 }
}

unsafe impl Zeroable for bool {
    fn zeroed() -> Self { false }
}

The unsafe on the trait means: “implementing this incorrectly can cause undefined behavior.” For Zeroable, the invariant is that zeroed() must return a valid value for the type. Not all types can be safely zeroed — for example, a NonZeroU32 zeroed would violate its invariant.

You can then use it safely:

fn create_zeroed_array<T: Zeroable, const N: usize>() -> [T; N] {
    std::array::from_fn(|_| T::zeroed())
}

let zeros: [i32; 5] = create_zeroed_array();
// [0, 0, 0, 0, 0]

FFI — Foreign Function Interface

FFI lets Rust call C functions and C call Rust functions. This is inherently unsafe because C has no safety guarantees.

Here’s a simulated example of C-style string operations:

/// Like C's strlen
unsafe fn c_strlen(ptr: *const u8) -> usize {
    let mut len = 0;
    unsafe {
        while *ptr.add(len) != 0 {
            len += 1;
        }
    }
    len
}

/// Like C's strcmp
unsafe fn c_strcmp(a: *const u8, b: *const u8) -> i32 {
    let mut i = 0;
    loop {
        let ca = unsafe { *a.add(i) };
        let cb = unsafe { *b.add(i) };
        if ca != cb {
            return ca as i32 - cb as i32;
        }
        if ca == 0 {
            return 0;
        }
        i += 1;
    }
}

In real FFI, you’d declare external C functions:

extern "C" {
    fn strlen(s: *const std::os::raw::c_char) -> usize;
    fn printf(format: *const std::os::raw::c_char, ...) -> i32;
}

And call them in unsafe blocks:

unsafe {
    let len = strlen(c_string.as_ptr());
}

The extern "C" block tells Rust these functions use the C calling convention. The compiler trusts you that the signatures are correct.

Transmute — Type Reinterpretation

std::mem::transmute reinterprets the bits of one type as another type:

fn float_to_bits(f: f32) -> u32 {
    unsafe { std::mem::transmute(f) }
}

fn bits_to_float(bits: u32) -> f32 {
    unsafe { std::mem::transmute(bits) }
}

This is useful for inspecting the binary representation of values:

fn inspect_float_bits(f: f32) -> (bool, u32, u32) {
    let bits = float_to_bits(f);
    let sign = bits >> 31 == 1;
    let exponent = (bits >> 23) & 0xFF;
    let mantissa = bits & 0x7FFFFF;
    (sign, exponent, mantissa)
}

fn is_nan_check(f: f32) -> bool {
    let bits = float_to_bits(f);
    let exponent = (bits >> 23) & 0xFF;
    let mantissa = bits & 0x7FFFFF;
    exponent == 0xFF && mantissa != 0
}

Use transmute sparingly. Prefer f32::to_bits() and f32::from_bits() for float conversion — they’re safe and do the same thing.

Union Types

Unions are like enums but without a tag. All fields share the same memory:

#[repr(C)]
union NumberUnion {
    integer: i64,
    float: f64,
    bytes: [u8; 8],
}

Reading a union field is unsafe because you might read the bits as the wrong type:

let mut num = NumberUnion { float: 3.14 };
let bytes = unsafe { num.bytes };  // Read float bits as bytes

Unions are mainly used for C interop. Rust enums with #[repr(C)] are usually a better choice.

Static Mutable Variables

Global mutable state is unsafe in Rust because it can cause data races:

static mut GLOBAL_COUNTER: i32 = 0;

fn increment_global() {
    unsafe {
        GLOBAL_COUNTER += 1;
    }
}

fn get_global() -> i32 {
    unsafe { GLOBAL_COUNTER }
}

Every access requires unsafe. In multi-threaded code, this is genuinely dangerous. Prefer AtomicI32 or Mutex<i32> instead:

use std::sync::atomic::{AtomicI32, Ordering};

static SAFE_COUNTER: AtomicI32 = AtomicI32::new(0);

fn increment_safe() {
    SAFE_COUNTER.fetch_add(1, Ordering::Relaxed);
}

No unsafe needed. The atomic type handles thread safety internally.

The Safe Wrapper Pattern in Practice

The most important skill with unsafe Rust is building safe abstractions. Here’s an example — a vector that guarantees at least one element:

struct NonEmptyVec<T> {
    first: T,
    rest: Vec<T>,
}

impl<T: Clone> NonEmptyVec<T> {
    fn from_vec(v: Vec<T>) -> Option<Self> {
        if v.is_empty() {
            return None;
        }
        let mut iter = v.into_iter();
        let first = iter.next().unwrap(); // safe: we checked non-empty
        let rest: Vec<T> = iter.collect();
        Some(Self { first, rest })
    }

    fn first(&self) -> &T {
        &self.first
    }

    fn last(&self) -> &T {
        self.rest.last().unwrap_or(&self.first)
    }

    fn len(&self) -> usize {
        1 + self.rest.len()
    }
}

This doesn’t use unsafe, but the pattern is the same: invariants are enforced by the type system. first() never panics because the struct always has at least one element. The safety is in the design, not in runtime checks.

Guidelines for Unsafe Code

When to Use Unsafe

  1. FFI — calling C libraries, system calls
  2. Performance-critical data structures — custom allocators, lock-free data structures
  3. Things the borrow checker can’t express — like splitting a slice into two mutable halves
  4. Hardware access — memory-mapped I/O in embedded systems

When NOT to Use Unsafe

  1. To bypass the borrow checker because your code doesn’t compile — fix the design instead
  2. For performance without measuring — safe Rust is usually fast enough
  3. When a safe alternative exists — use AtomicI32 instead of static mut

Rules for Writing Unsafe

  1. Minimize the unsafe block — put only the unsafe operations inside unsafe {}
  2. Document the invariants — explain what the caller must guarantee
  3. Wrap in safe APIs — users should never need to write unsafe
  4. Test thoroughly — unsafe bugs are undefined behavior, not panics
  5. Use Miricargo +nightly miri test detects undefined behavior at runtime
  6. Consider #[deny(unsafe_op_in_unsafe_fn)] — requires explicit unsafe blocks even inside unsafe functions (this is the default in edition 2024)

Auditing Unsafe Code

When reviewing unsafe code, check:

  • Are raw pointers always valid when dereferenced?
  • Are all allocated memory blocks freed exactly once?
  • Are there any data races on shared mutable state?
  • Are all type transmutations between types of the same size?
  • Are FFI function signatures correct?

Tools like cargo-audit, cargo-geiger (counts unsafe usage), and Miri help with auditing.

Source Code

You can find the complete source code for this tutorial on GitHub:

kemalcodes/rust-tutorial (branch: tutorial-30-unsafe)

Series Recap

This is the final tutorial in the Rust series. Here’s what we covered across 30 tutorials:

Basics (1-8): Installation, variables, ownership, borrowing, structs, enums, error handling

Intermediate (9-16): Traits, generics, lifetimes, closures, smart pointers, concurrency, async, channels

Applied (17-22): HTTP with reqwest, REST APIs with Axum, databases with SQLx, modules, testing, serialization with Serde

Advanced (23-30): CLI tools with Clap, file I/O, macros, collections, embedded, AI/ML, WebAssembly, unsafe Rust

You now have a solid foundation in Rust. Build something with it!