How to Dereference Raw Pointers Safely

Dereference raw pointers in Rust by wrapping the access in an unsafe block to manually verify memory safety.

The Napkin Address

You're integrating a C library that hands you a pointer to a buffer of sensor data. You have the address in a *const u8. You try to read the first byte, and the compiler refuses to compile. It throws an error about dereferencing a raw pointer. You need to step outside Rust's safety net and tell the compiler you've verified the memory is valid. That requires an unsafe block and a clear understanding of what you're promising.

Raw pointers are the escape hatch for memory safety. They let you access memory directly, but they also let you shoot yourself in the foot. Dereferencing them safely isn't about magic. It's about discipline. You check the pointer. You document the checks. You wrap the access in unsafe. You move on.

Raw Pointers Are Just Numbers

A raw pointer is a memory address. That's all it is. It holds a number that points to a location in RAM. It does not track lifetimes. It does not guarantee the memory exists. It does not guarantee the memory is initialized. It does not guarantee the type is correct.

Dereferencing a raw pointer is like walking to a house address written on a napkin. You might find a house. You might find a park. You might find a cliff. Rust won't let you walk there without a waiver. The unsafe block is that waiver. It tells the compiler, "I have checked the address. I promise the memory is valid. If I'm wrong, the program can crash or do anything."

The compiler cannot check raw pointers. It trusts you. If you lie, you get undefined behavior. The program might crash. It might corrupt data. It might appear to work on your machine and fail in production. The burden of proof is on you.

Minimal Example

You can create a raw pointer from a reference safely. The danger is reading back from the pointer.

fn main() {
    // Create a value on the stack.
    let x = 42;
    
    // Convert reference to raw pointer. This is safe.
    // The compiler knows &x is valid, so the pointer is valid.
    let ptr: *const i32 = &x;
    
    // Dereferencing requires unsafe because the compiler
    // cannot verify ptr is valid at this point.
    unsafe {
        // SAFETY: ptr was created from &x, which is alive
        // and valid in this scope.
        println!("Value: {}", *ptr);
    }
}

The conversion &x to *const i32 is safe. You can always turn a reference into a raw pointer. The reverse direction requires unsafe. The // SAFETY: comment documents why the dereference is safe. In this case, the pointer came from a reference that is still alive.

Convention aside: always write // SAFETY: comments before unsafe blocks. The community expects them. They are not checked by the compiler. They are for the human reader. If you can't write the comment, you probably don't have a safety argument.

The Unsafe Contract

Dereferencing a raw pointer requires three invariants. If any one fails, you have undefined behavior.

  1. Non-null: The pointer must not be null.
  2. Aligned: The pointer must be aligned for the type T.
  3. Valid: The pointer must point to allocated and initialized memory of type T.

These rules apply to both *const T and *mut T. The mut keyword only affects whether you can write through the pointer. It does not relax the safety rules. In fact, *mut T has stricter aliasing rules. You cannot have two *mut T pointing to the same memory. You cannot have a *mut T and a *const T aliasing the same memory if either writes.

If you try to dereference without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This error is a guardrail. It stops you from accidentally reading garbage or crashing the program. It forces you to acknowledge the risk.

Alignment Matters

Alignment is the silent killer. Every type has an alignment requirement. u8 is aligned to 1 byte. u32 is aligned to 4 bytes. usize is aligned to 8 bytes on 64-bit systems. If you dereference a pointer that is not aligned, you get undefined behavior. The program might crash with a segfault. It might silently produce wrong results.

fn main() {
    // Create a buffer of bytes.
    let buf = [0u8; 16];
    
    // Get a pointer to the first byte.
    let ptr = buf.as_ptr();
    
    // Cast to usize pointer. This is dangerous.
    // The address might not be aligned for usize.
    let usize_ptr = ptr as *const usize;
    
    // SAFETY: This is only safe if ptr is aligned for usize.
    // We cannot guarantee that here. This is UB if misaligned.
    unsafe {
        println!("Value: {}", *usize_ptr);
    }
}

This code is undefined behavior if buf is not aligned to 8 bytes. The compiler does not check alignment. You must check it yourself. Use ptr.align_offset(align) to check alignment. Or use ptr::from_ref to get a pointer with compile-time alignment guarantees.

Trust the borrow checker for alignment. If you have a reference, it is aligned. If you cast to a raw pointer, it stays aligned. If you do arithmetic, you might break alignment.

Realistic Example: A Safe Wrapper

Real code rarely dereferences raw pointers directly. You wrap them in a safe abstraction. The wrapper enforces the invariants. The unsafe block is hidden inside the wrapper.

/// A wrapper around a raw pointer that enforces safety checks.
struct SafeBuffer {
    ptr: *const u8,
    len: usize,
}

impl SafeBuffer {
    /// Creates a new SafeBuffer.
    ///
    /// # Safety
    /// `ptr` must point to `len` bytes of valid memory.
    /// The memory must remain valid for the lifetime of SafeBuffer.
    pub unsafe fn new(ptr: *const u8, len: usize) -> Self {
        // SAFETY: Caller guarantees ptr is valid for len bytes.
        // We trust the SAFETY contract of this function.
        Self { ptr, len }
    }

    /// Reads a byte at index.
    /// Returns None if index is out of bounds.
    pub fn get(&self, index: usize) -> Option<u8> {
        // Bounds check.
        if index >= self.len {
            return None;
        }

        // Calculate address using safe arithmetic simulation.
        // ptr.add is preferred over ptr.offset for this use case.
        let addr = self.ptr.add(index);

        // SAFETY:
        // 1. ptr is non-null and aligned (from new).
        // 2. index < len, so add(index) is within bounds.
        // 3. Memory is valid for the lifetime of SafeBuffer.
        unsafe {
            Some(*addr)
        }
    }
}

The new function is unsafe. It requires the caller to guarantee the pointer is valid. The get function is safe. It checks bounds. It calculates the address. It dereferences inside an unsafe block with a // SAFETY: comment that lists the invariants.

Convention aside: use ptr.add(index) instead of ptr.offset(index) when simulating safe arithmetic. add takes a usize and is clearer. offset takes an isize and is more raw. The community prefers add for index-based access.

Read vs Deref

There is a subtle difference between *ptr and ptr.read(). Both read the value. But *ptr moves the value. If the type has a destructor, moving it might cause issues. ptr.read() is a bitwise copy. It does not run drop logic.

use std::ptr;

fn swap_raw(a: *mut i32, b: *mut i32) {
    // SAFETY: a and b are valid, aligned, and non-overlapping.
    unsafe {
        // read() copies the value without dropping.
        // This is safe for i32, which has no drop glue.
        let temp = a.read();
        a.write(b.read());
        b.write(temp);
    }
}

For types with drop glue, read() is dangerous. If you read a value and then the memory is reused, the original value might be dropped twice. Use ptr::swap instead. It handles the swap safely.

Use ptr.read() when you need to read a value without consuming the pointer, and the type does not have drop glue. Use *ptr when you want to move the value and let the compiler handle drop logic. Use ptr::write when you want to write a value without dropping the old value.

Pitfalls and Errors

Raw pointers are a minefield. Here are the common traps.

Null pointers: Always check ptr.is_null() before dereferencing. If the pointer comes from C, it might be null. If you dereference null, you get undefined behavior.

Dangling pointers: A pointer to freed memory is dangling. If you dereference a dangling pointer, you get undefined behavior. The memory might be reused. You might read garbage. You might overwrite other data. Keep track of lifetimes manually.

Misaligned pointers: Casting a *const u8 to *const usize does not guarantee alignment. Check alignment with ptr.align_offset(). Or use ptr::from_ref to get a pointer with compile-time alignment.

Mutable aliasing: If you have a *mut T, you cannot have any other pointer to the same memory. Not even a *const T. If you violate this, you get undefined behavior. The compiler might optimize based on the assumption that there is no aliasing.

Uninitialized memory: If you allocate memory with alloc, it is uninitialized. Dereferencing uninitialized memory is undefined behavior. You must write to it first. Use ptr::write to initialize.

E0133: If you forget the unsafe block, you get E0133. The compiler says "dereference of raw pointer requires unsafe". This is a good error. It stops you from accidental UB.

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

Decision Matrix

Use unsafe { *ptr } when you have verified the pointer is non-null, aligned, and points to valid memory, and you need to read the value. Use ptr.read() when you need a bitwise copy of the value without running drop logic, and the type does not have drop glue. Use ptr::from_ref(&value) when you want to create a raw pointer from a reference and get compile-time proof of validity. Use NonNull<T> when you want to encode the non-null invariant in the type system, saving space in Option. Use std::slice::from_raw_parts when you have a pointer and length and need a slice, after verifying the region is valid. Use ptr::write when you want to write a value without dropping the old value. Use ptr::swap when you want to swap two values pointed to by raw pointers.

Where to go next