Unsafe Rust Patterns
Unsafe Rust is not a separate language. It’s a escape hatch that allows you to tell the compiler “I know what I’m doing, trust me on this.” While Rust’s safety guarantees are powerful, they can not express every valid program. Low-level systems programming, hardware interaction, foreign function interfaces, and certain performance optimizations require operations that the compiler cannot verify as safe.
The unsafe keyword doesn’t disable Rust’s safety checks; it expands what you’re allowed to do. You’re still protected from type confusion, use-after-free in safe code surrounding your unsafe blocks, and many other pitfalls. What unsafe enables are five specific superpowers that the compiler cannot verify automatically:
- Dereferencing raw pointers – Reading or writing through
*const Tand*mut T - Calling unsafe functions – Functions that have unchecked preconditions
- Accessing or modifying static mutable variables – Global mutable state
- Implementing unsafe traits – Traits with invariants the compiler can’t verify
- Accessing fields of unions – Type-punning and low-level tricks
This chapter explores patterns for using unsafe code responsibly. The goal is not to avoid unsafe code—that would be impossible for low-level libraries—but to build safe abstractions over unsafe foundations. Every unsafe block should be surrounded by safe APIs that enforce invariants, document preconditions, and prevent misuse.
The patterns we’ll explore show how to:
- Manipulate raw pointers safely while maintaining invariants
- Interface with C code without compromising Rust’s safety
- Handle uninitialized memory correctly using
MaybeUninit - Use transmute sparingly and correctly
- Build safe APIs that encapsulate unsafe internals
The golden rule: Unsafe code is not about being unsafe. It’s about maintaining safety invariants that the compiler cannot verify. Every unsafe block should have a comment explaining why it’s correct. If you can’t explain why it’s safe, it probably isn’t.
Pattern 1: Raw Pointer Manipulation
Problem: Need manual memory management for custom data structures. Borrow checker can’t express bidirectional relationships (tree with parent pointers).
Solution: Use raw pointers (*const T, *mut T) with explicit safety. Creating pointers is safe, dereferencing requires unsafe.
Why It Matters: Enables implementing Vec, LinkedList, HashMap from scratch. Custom allocators power memory pools.
Use Cases: Custom collections (linked lists, trees, graphs), custom allocators and memory pools, memory-mapped I/O, FFI with C code, intrusive data structures, zero-copy parsing, hardware drivers.
Raw pointers (*const T and *mut T) are Rust’s unmanaged pointers. Unlike references, they have no borrowing rules, no lifetime tracking, and no automatic dereferencing. They’re what you get when you need manual memory management or when interfacing with systems that don’t speak Rust’s language of ownership.
When raw pointers are necessary:
- Implementing custom data structures (linked lists, trees with parent pointers)
- FFI with C code that expects raw pointers
- Memory-mapped I/O for hardware access
- Custom allocators and memory pools
- Performance-critical code avoiding bounds checks
The key difference from references: raw pointers don’t promise validity. A *const T might point to valid memory, freed memory, or complete garbage. The compiler won’t stop you from creating them, but dereferencing requires unsafe because that’s where things can go wrong.
Example: Raw Pointer Usage
Creating raw pointers is safe—it’s just taking an address. The danger comes when you dereference them, asserting “this memory is valid and properly aligned.”
Dereferencing requires unsafe because the compiler can’t verify that claim; arbitrary address pointers are UB unless they point to valid memory.
#![allow(unused)]
fn main() {
fn raw_pointer_basics() {
let mut num = 42;
let r1: *const i32 = # // Immutable raw ptr
let r2: *mut i32 = &mut num; // Mutable raw ptr
let address = 0x12345usize;
let r3 = address as *const i32; // May be invalid!
unsafe {
println!("r1 points to: {}", *r1);
*r2 = 100;
println!("num is now: {}", num);
// r3 dereference would be UB - random memory!
}
}
// Create pointer from reference, deref in unsafe block
let x = 42;
let ptr: *const i32 = &x;
unsafe { println!("Value: {}", *ptr); }
}
Why this pattern exists: Sometimes you need to store pointers in data structures where the borrow checker can’t track the relationships. A tree node with a parent pointer, for example—the parent outlives the child, but Rust’s borrow checker can’t express that bidirectional relationship without causing issues.
Example: Pointer Arithmetic
Pointer arithmetic lets you navigate memory—ptr.add(n) advances by n * size_of::<T>() bytes, fundamental for custom collections and contiguous layouts.
Going out of bounds is undefined behavior even without dereferencing; the compiler assumes it never happens and optimizes accordingly.
#![allow(unused)]
fn main() {
fn pointer_arithmetic() {
let arr = [1, 2, 3, 4, 5];
let ptr: *const i32 = arr.as_ptr();
unsafe {
for i in 0..arr.len() {
// ptr + i * sizeof(i32)
let element_ptr = ptr.add(i);
println!("Element {}: {}", i, *element_ptr);
}
let third = ptr.add(2); // Points to third element
println!("Third element: {}", *third);
// ptr.add(10) would be UB - out of bounds!
}
}
// Usage: Iterate array via pointer offset
let data = [10, 20, 30];
let ptr = data.as_ptr();
unsafe { println!("Second: {}", *ptr.add(1)); }
}
The critical rule: Pointer arithmetic must stay within the bounds of the original allocation (or one byte past the end). Going beyond is undefined behavior even if you don’t dereference. The CPU’s memory protection won’t save you—UB means the compiler can assume it never happens and optimize accordingly, leading to bizarre bugs.
When to use this: Implementing iterators over custom collections, parsing binary protocols, working with memory-mapped files where you need to jump to specific offsets.
Example: Building a Raw Vec-like Structure
This pattern separates allocation from element storage—RawVec handles raw memory while higher-level code tracks initialization.
This separation of concerns, used by std::vec::Vec and custom allocators, makes unsafe code easier to audit and maintain.
#![allow(unused)]
fn main() {
use std::alloc::{alloc, dealloc, realloc, Layout};
use std::ptr;
/// Manages raw memory allocation for a vector.
/// Does NOT track which elements are initialized!
pub struct RawVec<T> {
ptr: *mut T, // Pointer to allocated memory
cap: usize, // Capacity (number of T that fit)
}
impl<T> RawVec<T> {
/// Creates an empty RawVec with no allocation.
pub fn new() -> Self {
RawVec {
ptr: std::ptr::null_mut(), // Safe when cap == 0
cap: 0,
}
}
/// Allocates memory for `cap` elements.
pub fn with_capacity(cap: usize) -> Self {
let layout = Layout::array::<T>(cap).unwrap();
let ptr = unsafe { alloc(layout) as *mut T };
if ptr.is_null() {
panic!("Allocation failed");
}
RawVec { ptr, cap }
}
/// Doubles capacity, or sets it to 1 if currently zero.
pub fn grow(&mut self) {
let new_cap = if self.cap == 0 { 1 } else { self.cap * 2 };
let new_layout = Layout::array::<T>(new_cap).unwrap();
let new_ptr = if self.cap == 0 {
unsafe { alloc(new_layout) as *mut T }
} else {
let old_layout = Layout::array::<T>(self.cap).unwrap();
unsafe {
realloc(
self.ptr as *mut u8,
old_layout,
new_layout.size()
) as *mut T
}
};
if new_ptr.is_null() {
panic!("Allocation failed");
}
self.ptr = new_ptr;
self.cap = new_cap;
}
pub fn ptr(&self) -> *mut T {
self.ptr
}
pub fn cap(&self) -> usize {
self.cap
}
}
impl<T> Drop for RawVec<T> {
fn drop(&mut self) {
if self.cap != 0 {
let layout = Layout::array::<T>(self.cap).unwrap();
unsafe {
dealloc(self.ptr as *mut u8, layout);
}
}
}
}
let mut v = RawVec::with_capacity(10);
v.grow(); // Doubles capacity to 20
}
Why this pattern? Separating raw allocation from element management clarifies responsibilities. RawVec handles memory, higher-level code handles Drop for elements. This is exactly how std::vec::Vec is implemented.
Safety invariants we maintain:
ptris either null (whencap == 0) or points to valid allocated memorycapaccurately reflects the allocation size- Deallocation uses the same layout as allocation
- We never dereference
ptrhere (no assumptions about initialization)
Example: Null Pointer Optimization with NonNull
NonNull<T> guarantees non-nullness, enabling the null pointer optimization: Option<NonNull<T>> is the same size as *mut T.
This saves 8 bytes per pointer in linked structures with millions of nodes, and unlike *mut T, NonNull<T> is covariant for lifetime subtyping.
#![allow(unused)]
fn main() {
use std::ptr::NonNull;
/// A node in a linked list using NonNull for efficiency.
struct Node<T> {
value: T,
next: Option<NonNull<Node<T>>>, // Same size as *mut
}
impl<T> Node<T> {
fn new(value: T) -> Self {
Node { value, next: None }
}
fn set_next(&mut self, next: NonNull<Node<T>>) {
self.next = Some(next);
}
}
/// A simple singly-linked list.
struct LinkedList<T> {
head: Option<NonNull<Node<T>>>,
tail: Option<NonNull<Node<T>>>,
len: usize,
}
impl<T> LinkedList<T> {
fn new() -> Self {
LinkedList {
head: None,
tail: None,
len: 0,
}
}
fn push_back(&mut self, value: T) {
let node = Box::new(Node::new(value));
let raw = Box::into_raw(node);
let node_ptr = NonNull::new(raw).unwrap();
unsafe {
if let Some(mut tail) = self.tail {
tail.as_mut().next = Some(node_ptr);
} else {
self.head = Some(node_ptr);
}
self.tail = Some(node_ptr);
}
self.len += 1;
}
}
impl<T> Drop for LinkedList<T> {
fn drop(&mut self) {
let mut current = self.head;
while let Some(node_ptr) = current {
unsafe {
let node = Box::from_raw(node_ptr.as_ptr());
current = node.next;
}
}
}
}
let mut list = LinkedList::new();
list.push_back(1);
}
Why NonNull? Three benefits:
- Memory efficiency:
Option<NonNull<T>>is the same size as a raw pointer - Safety marker: Guarantees non-null, catching bugs at creation time
- Covariance: Unlike
*mut T,NonNull<T>is covariant overT
When to use: Linked data structures, graph nodes, intrusive collections. Anywhere you’d use raw pointers but can guarantee non-null.
Pattern 2: FFI and C Interop
Problem: Need to call C libraries (operating system, drivers, databases, graphics). C has no concept of ownership, borrowing, or lifetimes.
Solution: Use extern "C" to declare/export functions with C ABI. #[repr(C)] for compatible struct layout.
Why It Matters: Unlocks entire C ecosystem (millions of libraries). System programming requires OS APIs (all C).
Use Cases: OS APIs (filesystem, networking, processes), database bindings (PostgreSQL, MySQL, SQLite), graphics libraries (OpenGL, Vulkan), compression (zlib, lz4), cryptography (OpenSSL), embedded drivers, legacy C code integration.
Foreign Function Interface (FFI) is how Rust talks to C, and by extension, the vast ecosystem of C libraries. This is unavoidable in systems programming—the operating system, graphics drivers, databases, and countless libraries all speak C at their boundaries.
The challenge: C has no concept of Rust’s ownership, borrowing, or lifetimes. A char* in C could be stack-allocated, heap-allocated, a string literal, or dangling memory. Rust must bridge this gap carefully.
Key FFI principles:
- Rust can call C functions, C can call Rust functions marked
extern "C" - Types must have compatible memory layouts (use
#[repr(C)]) - Ownership transfer must be explicit and documented
- String encoding matters: C uses null-terminated, Rust uses UTF-8 slices
Example: Basic C Function Binding
Declare external C functions with extern "C" blocks; calling them requires unsafe since Rust can’t verify their contracts.
Use c_int, c_char, c_void from std::os::raw for portability—the libc crate provides standard C library bindings.
#![allow(unused)]
fn main() {
//=========================================================
// Declare external C functions from the standard C library
//=========================================================
extern "C" {
fn abs(input: i32) -> i32;
fn strlen(s: *const std::os::raw::c_char) -> usize;
fn malloc(size: usize) -> *mut std::os::raw::c_void;
fn free(ptr: *mut std::os::raw::c_void);
}
fn use_c_functions() {
unsafe {
let result = abs(-42);
println!("abs(-42) = {}", result);
let c_str = b"Hello\0";
let ptr = c_str.as_ptr() as *const std::os::raw::c_char;
let len = strlen(ptr);
println!("String length: {}", len);
let ptr = malloc(100);
if !ptr.is_null() {
free(ptr);
}
}
}
// Usage: Call C's abs() function
extern "C" { fn abs(n: i32) -> i32; }
let result = unsafe { abs(-42) }; // Returns 42
}
Why unsafe? The compiler can’t verify that:
strlenwon’t read past the end of the stringmallocreturns are checked before usefreeis called exactly once permalloc
These are contracts you must uphold, documented in C headers and man pages.
Example: Working with C Strings
C strings are null-terminated byte arrays; CString owns one for passing to C, while CStr borrows one (like &str for C).
Use CString::new() to create (panics on interior nulls) and CStr::to_string_lossy() to handle invalid UTF-8 gracefully.
#![allow(unused)]
fn main() {
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
/// Converts a Rust string to a C-owned string.
/// Caller must free with free_rust_c_string().
fn rust_to_c_string(s: &str) -> *mut c_char {
let c_string = CString::new(s).expect("CString::new failed");
c_string.into_raw() // Transfers ownership to caller
}
/// Converts a C string to a Rust String (copying data).
///
/// # Safety
/// - `c_str` must be a valid null-terminated C string
/// - The memory must remain valid for this call
unsafe fn c_to_rust_string(c_str: *const c_char) -> String {
let c_str = CStr::from_ptr(c_str);
c_str.to_string_lossy().into_owned()
}
/// Frees a C string created by rust_to_c_string().
///
/// # Safety
/// - `ptr` must have been created by rust_to_c_string()
/// - `ptr` must not be used after this call
unsafe fn free_rust_c_string(ptr: *mut c_char) {
if !ptr.is_null() {
let _ = CString::from_raw(ptr);
}
}
// Example usage
fn c_string_example() {
let c_str = rust_to_c_string("Hello from Rust");
unsafe {
let rust_str = c_to_rust_string(c_str);
println!("Back to Rust: {}", rust_str);
free_rust_c_string(c_str);
}
}
// Convert Rust string to C, pass to C function
let c_str = CString::new("hello").unwrap();
extern "C" { fn puts(s: *const c_char); }
unsafe { puts(c_str.as_ptr()); }
}
Critical pattern: Ownership transfer must be explicit. into_raw() says “Rust, stop tracking this.” from_raw() says “Resume tracking so you can drop it.” Missing either causes memory leaks or double-frees.
Encoding issues: C strings are byte arrays, not necessarily UTF-8. Use to_string_lossy() to handle invalid UTF-8 gracefully, or to_str() to fail fast.
Example: C Struct Interop
#[repr(C)] ensures Rust uses C’s memory layout (field order, padding, alignment) instead of Rust’s optimized default.
Use c_int, c_char from std::os::raw for portability; enums need #[repr(C)] or #[repr(u8)] for explicit discriminants.
#![allow(unused)]
fn main() {
use std::os::raw::{c_int, c_char};
/// A point with C-compatible layout.
#[repr(C)]
struct Point {
x: c_int, // Use c_int for portability
y: c_int,
}
/// A person struct that C can understand.
#[repr(C)]
struct Person {
name: *const c_char, // C expects raw ptrs
age: c_int,
height: f64,
}
/// An enum with explicit discriminant values for C.
#[repr(C)]
enum Status {
Success = 0,
Error = 1,
Pending = 2,
}
// Declare C functions that work with these structs
extern "C" {
fn process_point(point: *const Point) -> c_int;
fn create_person(
name: *const c_char,
age: c_int
) -> *mut Person;
fn free_person(person: *mut Person);
}
// Usage
fn use_c_structs() {
let point = Point { x: 10, y: 20 };
unsafe {
let result = process_point(&point);
println!("Result: {}", result);
}
}
// Usage: Create C-compatible struct and pass pointer to C
#[repr(C)] struct Vec2 { x: f32, y: f32 }
let v = Vec2 { x: 1.0, y: 2.0 };
// Pass &v as *const Vec2 to C functions
}
Why #[repr(C)]? Rust can reorder struct fields for optimization. C can’t—field order is part of the ABI contract. #[repr(C)] locks in C’s layout.
Common pitfall: Using Rust types (String, &str, Option<T>) in #[repr(C)] structs. These have Rust-specific layouts. Use raw pointers and C-compatible types instead.
Example: Creating a Safe Wrapper for C Libraries
Create a safe Rust wrapper that encapsulates unsafe FFI: Drop handles cleanup, Result handles errors, and users never see unsafe.
The public API hides raw pointers and maintains invariants automatically—callers get a safe, idiomatic Rust interface.
#![allow(unused)]
fn main() {
use std::ffi::CString;
use std::os::raw::c_char;
// Unsafe C API (these would come from a real C library)
extern "C" {
fn create_context() -> *mut std::os::raw::c_void;
fn destroy_context(ctx: *mut std::os::raw::c_void);
fn context_do_work(
ctx: *mut std::os::raw::c_void,
data: *const c_char
) -> i32;
}
// Safe Rust wrapper for the C context API.
// Ensures the context is properly created and destroyed.
pub struct Context {
inner: *mut std::os::raw::c_void,
}
impl Context {
// Creates a new context.
// Returns None if C library fails to create context.
pub fn new() -> Option<Self> {
let ptr = unsafe { create_context() };
if ptr.is_null() {
None
} else {
Some(Context { inner: ptr })
}
}
// Performs work with the given data.
// Returns Ok(result) on success, Err(message) on failure.
pub fn do_work(
&mut self,
data: &str
) -> Result<i32, String> {
let c_data = CString::new(data)
.map_err(|e| e.to_string())?;
let result = unsafe {
context_do_work(self.inner, c_data.as_ptr())
};
if result >= 0 {
Ok(result)
} else {
Err(format!("Failed with code: {}", result))
}
}
}
impl Drop for Context {
fn drop(&mut self) {
unsafe {
destroy_context(self.inner);
}
}
}
// Usage: Safe Rust API wrapping unsafe C calls
let mut ctx = Context::new().expect("Failed to create context");
ctx.do_work("process this").expect("Work failed");
// ctx automatically cleaned up when dropped
}
This pattern solves multiple problems:
- Lifetime management:
Dropensures cleanup - Type safety: Users can’t misuse the raw pointer
- Error handling: Converts C error codes to Rust
Result - String safety: Handles null termination automatically
When to use: Every time you wrap a C library. Users should never see unsafe in the public API unless absolutely necessary.
Example: Callback Functions (C to Rust)
Callbacks must use extern "C" for the C calling convention and must never panic (unwinding across FFI is undefined behavior).
C libraries pass a void* user data pointer through callbacks; cast it back to your Rust type to maintain state without globals.
#![allow(unused)]
fn main() {
use std::os::raw::c_int;
// Type alias for C callback
type Callback = extern "C" fn(c_int) -> c_int;
extern "C" {
fn register_callback(cb: Callback);
fn trigger_callback(value: c_int);
}
// Rust function with C calling convention.
// This can be called from C code.
extern "C" fn my_callback(value: c_int) -> c_int {
println!("Callback called with: {}", value);
value * 2
}
fn callback_example() {
unsafe {
register_callback(my_callback);
trigger_callback(42);
}
}
// Advanced: Callback with user data (context pointer)
type CbWithData = extern "C" fn(
*mut std::os::raw::c_void,
c_int
) -> c_int;
extern "C" fn callback_with_context(
user_data: *mut std::os::raw::c_void,
value: c_int
) -> c_int {
unsafe {
let data = &mut *(user_data as *mut i32);
*data += value;
*data
}
}
// Example usage
fn callback_with_state_example() {
let mut state = 0i32;
extern "C" {
fn register_callback_with_data(
cb: CbWithData,
user_data: *mut std::os::raw::c_void
);
fn trigger_callback_with_data(value: c_int);
}
unsafe {
let ptr = &mut state as *mut i32;
register_callback_with_data(
callback_with_context,
ptr as *mut std::os::raw::c_void
);
trigger_callback_with_data(10);
}
println!("State after callback: {}", state);
}
// Usage: Define callback with C calling convention
extern "C" fn on_event(code: c_int) -> c_int { code * 2 }
// Pass on_event as function pointer to C library
}
Critical rules for callbacks:
- Use
extern "C": Ensures C calling convention - No panics: Unwinding through C is UB. Use
catch_unwind - Document user_data: What type must be passed? Who owns it?
- Lifetime safety: Callback must not outlive referenced data
User data pattern: C libraries pass a void* context pointer through to callbacks. This lets you maintain state without globals.
Pattern 3: Uninitialized Memory Handling
Problem: Large arrays (1MB) on stack cause overflow. Reading from I/O into buffers wastes initialization.
Solution: Use MaybeUninit<T> to work with possibly-uninitialized memory safely. MaybeUninit::uninit() creates uninitialized, write() initializes, assume_init() asserts initialization.
Why It Matters: Prevents stack overflow: [i32; 1_000_000] crashes, MaybeUninit array succeeds. 2-3x faster for bulk initialization—no double-init.
Use Cases: Large stack arrays (>4KB), reading from files/sockets/FFI into buffers, performance-critical initialization, FFI out-parameters, deserializing from binary, reusing buffers without clearing.
Memory starts uninitialized. Creating a Vec doesn’t fill it with zeros; allocating a buffer doesn’t clear it. For performance, you often want to initialize memory piecemeal—read into it from a file, compute values on demand, or skip initialization for data you’ll immediately overwrite.
The problem: Rust’s safety model assumes all values are initialized. Reading uninitialized memory is instant undefined behavior. Even casting an i32 from uninitialized memory (without using it) is UB.
MaybeUninit<T> solves this: it’s a type that may or may not hold a valid T. You can work with it safely, then assert initialization when you’re ready.
Example: Using MaybeUninit for Arrays
Large stack arrays can overflow if initialized naively; MaybeUninit lets you allocate space without initializing, then fill elements individually.
MaybeUninit<T> is the same size as T but tells the compiler “this might not be valid yet”; assume_init() asserts initialization is complete.
#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;
/// Create large array efficiently without stack overflow.
fn create_array_uninit() -> [i32; 1000] {
let mut arr: [MaybeUninit<i32>; 1000] = unsafe {
MaybeUninit::uninit().assume_init()
};
for (i, elem) in arr.iter_mut().enumerate() {
*elem = MaybeUninit::new(i as i32);
}
unsafe {
std::mem::transmute(arr)
}
}
// Better: Use the newer stabilized API
fn create_array_uninit_safe() -> [i32; 1000] {
let mut arr = MaybeUninit::uninit_array::<1000>();
for (i, elem) in arr.iter_mut().enumerate() {
elem.write(i as i32);
}
unsafe { MaybeUninit::array_assume_init(arr) }
}
// Create large array without stack overflow
let mut uninit: MaybeUninit<[u8; 10000]> = MaybeUninit::uninit();
unsafe { (*uninit.as_mut_ptr()).fill(0); } // Init all
let arr = unsafe { uninit.assume_init() };
}
Why this works: MaybeUninit<T> is the same size as T, but Rust knows it might not be initialized. Operations on it are safe until you call assume_init(), which asserts “I promise this is initialized.”
When to use: Large stack arrays, reading data from external sources (sockets, files), performance-critical initialization.
Example: Partial Initialization
MaybeUninit enables field-by-field struct initialization using addr_of_mut! to get field pointers without creating references.
Critical: creating a reference (&mut) to uninitialized memory is instant UB—use addr_of_mut! then write() for each field.
#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;
struct ComplexStruct {
field1: String,
field2: Vec<i32>,
field3: Box<i32>,
}
fn initialize_complex_struct() -> ComplexStruct {
let mut uninit: MaybeUninit<ComplexStruct> =
MaybeUninit::uninit();
let ptr = uninit.as_mut_ptr();
unsafe {
// addr_of_mut! gets field ptr without creating refs
std::ptr::addr_of_mut!((*ptr).field1)
.write(String::from("hello"));
std::ptr::addr_of_mut!((*ptr).field2)
.write(vec![1, 2, 3]);
std::ptr::addr_of_mut!((*ptr).field3)
.write(Box::new(42));
uninit.assume_init()
}
}
// Usage: Initialize struct field-by-field
let s = initialize_complex_struct();
println!("{}, {:?}, {}", s.field1, s.field2, s.field3);
}
Critical detail: Use addr_of_mut! to get field pointers without creating references. Creating a &mut T to uninitialized memory is UB, even if you don’t read it. addr_of_mut! avoids this.
When to use: Deserializing from binary formats, constructing objects with complex dependencies, FFI out-parameters.
Example: Reading Uninitialized Memory (What NOT to Do)
Reading uninitialized memory is undefined behavior—the compiler might delete your code, produce garbage, or cause bugs elsewhere.
Even creating a reference to uninitialized memory is UB; use Miri (cargo +nightly miri test) to detect these issues.
#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;
fn undefined_behavior_example() {
let mut uninit: MaybeUninit<i32> = MaybeUninit::uninit();
// ✅ SAFE: Writing to uninitialized memory
uninit.write(42);
let value = unsafe { uninit.assume_init() };
println!("Value: {}", value);
}
fn actual_undefined_behavior() {
let uninit: MaybeUninit<i32> = MaybeUninit::uninit();
// ❌ UB: Reading uninitialized memory!
// let v = unsafe { uninit.assume_init() }; // UB!
}
// Usage: Correct pattern - write before assume_init
let mut x = MaybeUninit::uninit();
x.write(42);
let val = unsafe { x.assume_init() }; // Safe: was initialized
}
What “undefined behavior” means: Not just “might crash.” The compiler can:
- Delete your code entirely
- Produce inconsistent results
- Corrupt memory elsewhere in your program
- Work fine in debug builds but break in release
Miri (Rust’s interpreter for detecting UB) will catch these issues. Use it: cargo +nightly miri test.
Example: Out-Parameter Pattern for C FFI
Many C functions write results through pointer arguments; MaybeUninit handles this by creating uninitialized storage to pass to C.
Only call assume_init() if C indicates success—this avoids initializing memory that C will immediately overwrite.
#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;
use std::os::raw::c_int;
extern "C" {
/// C function that writes to an out parameter.
/// Returns 0 on success, non-zero on error.
fn get_value(out: *mut c_int) -> c_int;
}
fn call_out_parameter_function() -> Option<i32> {
let mut value = MaybeUninit::uninit();
let result = unsafe {
get_value(value.as_mut_ptr())
};
if result == 0 {
Some(unsafe { value.assume_init() })
} else {
None
}
}
// Usage
if let Some(v) = call_out_parameter_function() {
println!("{}", v);
}
}
Pattern: Create MaybeUninit, pass its pointer to C, check return code, assume init only on success.
Why this is safe: MaybeUninit::as_mut_ptr() gives a raw pointer that C can write to. If C doesn’t write (error case), we don’t call assume_init(), avoiding UB.
Example: Initializing Arrays from External Functions
MaybeUninit prevents wasteful double-initialization when filling buffers from external sources (files, sockets, hardware).
Create Vec<MaybeUninit<u8>>, pass its pointer to C, then transmute to Vec<u8>—safe because layouts are identical and C initialized the bytes.
#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;
extern "C" {
/// Fills buffer with data.
/// Returns 0 on success, non-zero on error.
fn fill_buffer(buffer: *mut u8, size: usize) -> i32;
}
fn read_into_buffer(size: usize) -> Option<Vec<u8>> {
let mut buf: Vec<MaybeUninit<u8>> = Vec::with_capacity(size);
unsafe {
buf.set_len(size); // Set len without init
}
let result = unsafe {
fill_buffer(buf.as_mut_ptr() as *mut u8, size)
};
if result == 0 {
// Safe: layouts identical, C initialized bytes
let buf = unsafe {
std::mem::transmute::<
Vec<MaybeUninit<u8>>,
Vec<u8>
>(buf)
};
Some(buf)
} else {
None
}
}
if let Some(data) = read_into_buffer(1024) { process(&data); }
}
Why transmute? Vec<MaybeUninit<u8>> and Vec<u8> have identical memory layout. transmute reinterprets the type without copying data.
Alternative: Use MaybeUninit::slice_assume_init_ref() for slices if you don’t need to transfer ownership.
Pattern 4: Transmute and Type Punning
Problem: Need bit-level reinterpretation for binary protocols. Numerical code needs bit manipulation (float bits).
Solution: Use transmute sparingly—most dangerous function in Rust. Prefer safe alternatives: to_bits()/from_bits() for floats, as casts for integers, pointer casts for references.
Why It Matters: Wrong transmute = instant UB (lifetime extension, size mismatch, invalid values). Proper use enables zero-copy parsing (10x faster).
Use Cases: Binary protocol parsing (network, file formats), bit manipulation in numerical code, zero-copy serialization/deserialization, converting between types with identical layout, enum discrimination, hardware register access.
std::mem::transmute is the most dangerous function in Rust. It reinterprets bytes from one type as another, no questions asked. Get it wrong and you invoke undefined behavior. Use it only when necessary and document why it’s correct.
Valid uses:
- Converting types with identical layout (
[u8; 4]tou32) - Implementing zero-copy protocols
- Optimized numerical code
Invalid uses:
- Extending lifetimes (creates dangling references)
- Converting different-sized types (compile error)
- Type confusion (pointers to different types)
Example: Basic Transmute
transmute reinterprets bits of one type as another—no conversion, just reinterpretation between same-sized types.
Prefer safe alternatives when they exist: f32::to_bits(), f32::from_bits(), integer as casts.
#![allow(unused)]
fn main() {
use std::mem;
fn transmute_basics() {
let a: u32 = 0x12345678;
let b: [u8; 4] = unsafe { mem::transmute(a) };
println!("Bytes: {:?}", b); // Endianness-dependent
let f: f32 = 3.14;
let bits: u32 = unsafe { mem::transmute(f) };
println!("Float bits: 0x{:08x}", bits);
// ✅ BETTER: Use safe built-in methods
let bits_safe = f.to_bits();
assert_eq!(bits, bits_safe);
let f2 = f32::from_bits(bits);
assert_eq!(f, f2);
}
// Get raw float bits (prefer to_bits())
let pi: f32 = 3.14159;
let bits = pi.to_bits(); // Safe alternative to transmute
}
Rule: If a safe alternative exists (.to_bits(), .from_bits(), as casts), use it. Transmute should be a last resort.
Endianness matters: u32 to [u8; 4] gives different byte orders on little-endian (x86) vs big-endian (some ARM, network byte order) systems.
Example: Transmuting References (Dangerous!)
Transmuting references breaks compiler assumptions about alignment, validity, and aliasing—instant UB if alignments differ. Prefer explicit pointer casts through raw pointers; they make reinterpretation visible and don’t accidentally change lifetimes.
#![allow(unused)]
fn main() {
use std::mem;
// DANGEROUS: Transmuting references
fn transmute_reference_unsafe() {
let x: &i32 = &42;
let y: &u32 = unsafe { mem::transmute(x) };
println!("Transmuted: {}", y);
}
// BETTER: Using pointer casting
fn transmute_reference_safer() {
let x: i32 = 42;
let ptr = &x as *const i32 as *const u32;
let y = unsafe { &*ptr };
println!("Casted: {}", y);
}
// SAFEST: Just use from_ne_bytes or as cast
fn safe_conversion() {
let x: i32 = 42;
let y = x as u32; // Sign-extends negative values
println!("Converted: {}", y);
}
// Usage: Safe integer reinterpretation via as cast
let signed: i32 = -1;
let unsigned: u32 = signed as u32; // 4294967295 (0xFFFFFFFF)
}
Why pointer casting is better: It’s explicit about what you’re doing and doesn’t accidentally change const-ness or lifetimes.
When transmuting references is UB:
- If the types have different alignment (e.g.,
&u8to&u32) - If the memory doesn’t satisfy the target type’s validity invariant (e.g., transmuting to
&boolwith value 2)
Example: Converting Between Slice Types
Reinterpreting slices uses slice::from_raw_parts() with careful size calculation; alignment matters or it’s instant UB.
The bytemuck crate provides cast_slice() which only compiles if the transmutation is proven safe at compile time.
#![allow(unused)]
fn main() {
use std::slice;
fn slice_transmute() {
let data: Vec<u32> = vec![0x12345678, 0x9abcdef0];
let bytes: &[u8] = unsafe {
slice::from_raw_parts(
data.as_ptr() as *const u8,
data.len() * std::mem::size_of::<u32>(),
)
};
println!("Bytes: {:?}", bytes);
// Reverse: bytes to u32 (must ensure alignment!)
}
// Usage: View u32 slice as bytes for serialization
let numbers: [u32; 2] = [1, 2];
let bytes: &[u8] = unsafe {
let ptr = numbers.as_ptr() as *const u8;
std::slice::from_raw_parts(ptr, 8)
};
}
Size calculation must be exact: data.len() elements × size_of::<u32>() bytes per element.
Safer alternatives: The bytemuck crate provides cast_slice, which only compiles if the transmutation is proven safe (no padding, correct alignment, etc.).
Example: Enum Discrimination
Getting an enum’s discriminant as a raw number via pointer cast is fragile—enums can have “niches” used for optimization.
Prefer match for known values or std::mem::discriminant() for opaque comparison—both are safe alternatives.
#![allow(unused)]
fn main() {
use std::mem;
#[repr(u8)]
enum MyEnum {
A = 0,
B = 1,
C = 2,
}
fn get_discriminant(e: &MyEnum) -> u8 {
unsafe { *(e as *const MyEnum as *const u8) }
}
fn enum_discriminant_safe(e: &MyEnum) -> u8 {
match e {
MyEnum::A => 0,
MyEnum::B => 1,
MyEnum::C => 2,
}
}
// Also safe: std::mem::discriminant
fn enum_discriminant_std(
e: &MyEnum
) -> std::mem::Discriminant<MyEnum> {
std::mem::discriminant(e)
}
let disc = enum_discriminant_safe(&MyEnum::B); // Returns 1
}
Why the unsafe version is wrong: Enums might have niches (unused bit patterns) that the compiler uses for optimization. Reading the raw bytes might give you unexpected values.
When you need the number: Use match or std::mem::discriminant. The latter returns an opaque type, useful for equality comparisons.
Example: Type Punning for Optimized Code
Unions provide a safer alternative to transmute for type punning: write one field, read another with explicit intent.
Reading inactive union fields is unsafe (bits might be invalid for that type); prefer safe to_bits()/from_bits() for floats.
#![allow(unused)]
fn main() {
union FloatUnion {
f: f32,
u: u32,
}
fn fast_float_bits(f: f32) -> u32 {
let union = FloatUnion { f };
unsafe { union.u } // Reading inactive union field is unsafe
}
// For real code, use the built-in method
fn correct_float_bits(f: f32) -> u32 {
f.to_bits()
}
let bits = fast_float_bits(3.14); // Raw bits via union
}
Union safety: Writing one field and reading another is safe only if both types are valid for all bit patterns (e.g., integers, floats). Reading a bool from a union where you wrote u8 would be UB if the byte is not 0 or 1.
Modern Rust: Unions are less necessary now that we have methods like to_bits() and from_bits(). Use library methods when available.
Example: When NOT to Use Transmute
Some transmute uses are always wrong: extending lifetimes creates dangling references, changing mutability violates aliasing rules. Transmute only checks size equality at compile time—if you’re using it to “fix” borrow checker errors, you’re creating bugs.
#![allow(unused)]
fn main() {
// WRONG: Extending lifetimes
fn extend_lifetime_bad<'a>(x: &'a str) -> &'static str {
unsafe { std::mem::transmute(x) } // UB: dangling reference
}
fn extend_lifetime_good<'a>(x: &'a str) -> &'a str {
x // Return with its real lifetime
}
// WRONG: Different sized types
fn different_sizes_bad() {
let x: u32 = 42;
// Won't compile: size mismatch
// let y: u64 = unsafe { std::mem::transmute(x) };
}
// WRONG: Changing mutability
fn change_mutability_bad(x: &i32) -> &mut i32 {
// UB: violates aliasing rules
// unsafe { std::mem::transmute(x) }
panic!("Can't safely do this")
}
// WRONG: Bypassing type safety
fn type_confusion_bad() {
let x: &str = "hello";
// UB: str has invariants that might be violated
// let y: &[u8] = unsafe { std::mem::transmute(x) };
}
}
Why these are UB:
- Lifetime extension creates dangling references
- Size mismatch writes to unintended memory
- Mutability changes violate aliasing rules
- Type confusion breaks type invariants
If the compiler accepts transmute, it doesn’t mean it’s safe! Transmute has a single compile-time check: sizes must match. Everything else is on you.
Pattern 5: Safe Abstractions Over Unsafe
Problem: Unsafe code scattered everywhere is error-prone. Hard to audit and maintain invariants.
Solution: Build safe types that encapsulate unsafe internals. Public API has no unsafe.
Why It Matters: Vec/String/Arc/Mutex prove pattern works—millions use them safely. Single audit point instead of scattered unsafe.
Use Cases: Custom collections (Vec, HashMap, LinkedList), synchronization primitives (Mutex, RwLock, atomics), custom allocators, FFI wrappers for C libraries, type-state APIs (builder patterns), intrusive data structures.
The goal of unsafe code is not to scatter unsafe blocks throughout your codebase. It’s to build safe abstractions—types and functions that encapsulate unsafe operations and expose only safe interfaces.
This pattern is everywhere in the standard library: Vec, String, Arc, Mutex all use unsafe internally but are safe to use. You can achieve the same.
Example: Building a Safe Vec
The public API (push, pop, get) is completely safe; all unsafe code is encapsulated in private implementation details.
We maintain invariants: ptr valid when cap > 0, elements 0..len initialized, len <= cap—unsafe foundations, safe interfaces.
#![allow(unused)]
fn main() {
use std::ptr;
use std::mem;
use std::alloc::{alloc, realloc, dealloc, Layout};
pub struct MyVec<T> {
ptr: *mut T,
len: usize,
cap: usize,
}
impl<T> MyVec<T> {
/// Creates an empty vector.
pub fn new() -> Self {
MyVec {
ptr: std::ptr::null_mut(), // OK when cap == 0
len: 0,
cap: 0,
}
}
/// Adds an element to the end of the vector.
pub fn push(&mut self, value: T) {
if self.len == self.cap {
self.grow();
}
unsafe {
ptr::write(self.ptr.add(self.len), value);
}
self.len += 1;
}
/// Removes and returns the last element, or None if empty.
pub fn pop(&mut self) -> Option<T> {
if self.len == 0 {
None
} else {
self.len -= 1;
unsafe {
Some(ptr::read(self.ptr.add(self.len)))
}
}
}
/// Returns a reference to the element at the given index.
pub fn get(&self, index: usize) -> Option<&T> {
if index < self.len {
unsafe {
Some(&*self.ptr.add(index))
}
} else {
None
}
}
pub fn len(&self) -> usize {
self.len
}
/// Grows capacity: doubles it, or sets to 1 if currently 0.
fn grow(&mut self) {
let new_cap = if self.cap == 0 { 1 } else { self.cap * 2 };
let new_layout = Layout::array::<T>(new_cap).unwrap();
let new_ptr = if self.cap == 0 {
unsafe { alloc(new_layout) as *mut T }
} else {
let old_layout = Layout::array::<T>(self.cap).unwrap();
unsafe {
realloc(
self.ptr as *mut u8,
old_layout,
new_layout.size(),
) as *mut T
}
};
if new_ptr.is_null() {
panic!("Allocation failed");
}
self.ptr = new_ptr;
self.cap = new_cap;
}
}
impl<T> Drop for MyVec<T> {
fn drop(&mut self) {
// Drop all elements
while self.pop().is_some() {}
// Deallocate memory
if self.cap != 0 {
let layout = Layout::array::<T>(self.cap).unwrap();
unsafe {
dealloc(self.ptr as *mut u8, layout);
}
}
}
}
// Safety: MyVec<T> can be sent to another thread if T can
unsafe impl<T: Send> Send for MyVec<T> {}
unsafe impl<T: Sync> Sync for MyVec<T> {}
// Usage: Safe API, unsafe internals hidden
let mut v = MyVec::new();
v.push(1); v.push(2); v.push(3);
assert_eq!(v.pop(), Some(3));
}
Invariants we maintain:
ptris either null (whencap == 0) or points to valid allocated memory forcapelements- Elements
0..lenare initialized,len..capare uninitialized len <= capalways- Deallocation uses the same layout as allocation
Why this is safe: Public methods (push, pop, get) never break invariants. Users can’t create invalid states.
Send/Sync: We implement these unsafe traits because MyVec<T> upholds the same safety guarantees as Vec<T>.
Example: Invariants and Documentation
Every unsafe fn needs a # Safety section; every unsafe block needs a // SAFETY: comment explaining correctness.
Type invariants should be documented on the struct—without this documentation, nobody can safely modify or verify the code.
#![allow(unused)]
fn main() {
/// A slice type that is guaranteed to be non-empty.
///
/// # Safety Invariants
/// - The inner slice must always have at least one element
/// - The pointer must be valid and properly aligned
/// - The data must be valid for lifetime 'a
pub struct NonEmptySlice<'a, T> {
slice: &'a [T],
}
impl<'a, T> NonEmptySlice<'a, T> {
// Creates a non-empty slice from a regular slice.
// Returns None if the slice is empty.
pub fn new(slice: &'a [T]) -> Option<Self> {
if slice.is_empty() {
None
} else {
Some(NonEmptySlice { slice })
}
}
// Creates a non-empty slice without checking.
// # Safety: Caller must ensure slice is not empty.
pub unsafe fn new_unchecked(slice: &'a [T]) -> Self {
debug_assert!(!slice.is_empty());
NonEmptySlice { slice }
}
/// Returns the first element (always exists).
pub fn first(&self) -> &T {
&self.slice[0]
}
/// Returns the last element (always exists).
pub fn last(&self) -> &T {
&self.slice[self.slice.len() - 1]
}
pub fn as_slice(&self) -> &[T] {
self.slice
}
}
let s = NonEmptySlice::new(&[1,2,3]).unwrap();
println!("{}", s.first());
}
Pattern: Every unsafe fn needs a “# Safety” section. Every unsafe block should have a “SAFETY:” comment explaining why it’s correct.
Invariants: Document what must always be true. These are the assumptions your unsafe code relies on.
Example: PhantomData for Type Safety
PhantomData is zero-sized but tells the compiler “I logically own a T”—affects drop checking and variance.
Use PhantomData<T> for ownership, PhantomData<*const T> for covariance without ownership, PhantomData<fn(T)> for contravariance.
#![allow(unused)]
fn main() {
use std::marker::PhantomData;
use std::ptr::NonNull;
/// A raw pointer wrapper that tracks ownership.
pub struct RawPtr<T> {
ptr: NonNull<T>,
// PhantomData tells compiler we "own" a T
_marker: PhantomData<T>,
}
impl<T> RawPtr<T> {
pub fn new(value: T) -> Self {
let boxed = Box::new(value);
RawPtr {
ptr: NonNull::new(Box::into_raw(boxed))
.unwrap(),
_marker: PhantomData,
}
}
pub fn as_ref(&self) -> &T {
unsafe { self.ptr.as_ref() }
}
pub fn as_mut(&mut self) -> &mut T {
unsafe { self.ptr.as_mut() }
}
}
impl<T> Drop for RawPtr<T> {
fn drop(&mut self) {
unsafe {
drop(Box::from_raw(self.ptr.as_ptr()));
}
}
}
// Safe because we own the T
unsafe impl<T: Send> Send for RawPtr<T> {}
unsafe impl<T: Sync> Sync for RawPtr<T> {}
let p = RawPtr::new(42);
println!("{}", p.as_ref()); // Owned heap value
}
Why PhantomData? Without it, the compiler wouldn’t know we “own” a T, so drop check wouldn’t run T’s destructor before RawPtr’s. The _marker field has zero runtime cost but provides compile-time guarantees.
Variance: PhantomData<T> makes RawPtr<T> covariant over T, meaning RawPtr<&'long T> can be used where RawPtr<&'short T> is expected.
Example: Building Safe APIs with Unsafe Internals
A spinlock using unsafe internally but exposing safe RAII guards: users call lock(), get access, and auto-release on drop.
The unsafe parts (UnsafeCell, atomics) are hidden; Acquire/Release ordering ensures cross-thread memory visibility.
#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicBool, Ordering};
/// Uses atomic operations and unsafe cell internally,
/// but provides safe locking through RAII guards.
pub struct SpinLock<T> {
locked: AtomicBool,
data: UnsafeCell<T>,
}
pub struct SpinLockGuard<'a, T> {
lock: &'a SpinLock<T>,
}
impl<T> SpinLock<T> {
pub fn new(data: T) -> Self {
SpinLock {
locked: AtomicBool::new(false),
data: UnsafeCell::new(data),
}
}
/// Acquires the lock, blocking until available.
/// Returns a guard that provides access to the data
/// and releases the lock when dropped.
pub fn lock(&self) -> SpinLockGuard<T> {
while self.locked.swap(true, Ordering::Acquire) {
std::hint::spin_loop();
}
SpinLockGuard { lock: self }
}
}
impl<'a, T> std::ops::Deref for SpinLockGuard<'a, T> {
type Target = T;
fn deref(&self) -> &T {
unsafe { &*self.lock.data.get() }
}
}
impl<'a, T> std::ops::DerefMut for SpinLockGuard<'a, T> {
fn deref_mut(&mut self) -> &mut T {
unsafe { &mut *self.lock.data.get() }
}
}
impl<'a, T> Drop for SpinLockGuard<'a, T> {
fn drop(&mut self) {
self.lock.locked.store(false, Ordering::Release);
}
}
// SAFETY: SpinLock properly synchronizes access to T
// The Acquire/Release ordering ensures memory visibility
unsafe impl<T: Send> Send for SpinLock<T> {}
unsafe impl<T: Send> Sync for SpinLock<T> {}
let lock = SpinLock::new(0);
*lock.lock() += 1; // RAII guard auto-unlocks
}
Safe API: Users never see unsafe. The lock/unlock mechanism is enforced by the type system—you can’t forget to unlock because the guard drops automatically.
Unsafe implementation: UnsafeCell allows interior mutability, atomics provide synchronization, but these are encapsulated.
Ordering matters: Acquire on lock, Release on unlock. This ensures memory writes before unlock are visible after lock on other threads.
Example: Testing Unsafe Code
Unsafe code requires rigorous testing: use DropCounter to verify no leaks/double-frees, test concurrent access with multiple threads.
Run cargo +nightly miri test to detect undefined behavior that compiles fine but is wrong.
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_my_vec_basic() {
let mut vec = MyVec::new();
vec.push(1);
vec.push(2);
vec.push(3);
assert_eq!(vec.get(0), Some(&1));
assert_eq!(vec.get(1), Some(&2));
assert_eq!(vec.get(2), Some(&3));
assert_eq!(vec.get(3), None);
}
#[test]
fn test_my_vec_pop() {
let mut vec = MyVec::new();
vec.push(1);
vec.push(2);
assert_eq!(vec.pop(), Some(2));
assert_eq!(vec.pop(), Some(1));
assert_eq!(vec.pop(), None);
}
#[test]
fn test_my_vec_drop() {
use std::sync::atomic::{AtomicUsize, Ordering as Ord};
static DROP_COUNT: AtomicUsize = AtomicUsize::new(0);
struct DropCounter;
impl Drop for DropCounter {
fn drop(&mut self) {
DROP_COUNT.fetch_add(1, Ord::SeqCst);
}
}
{
let mut vec = MyVec::new();
vec.push(DropCounter);
vec.push(DropCounter);
vec.push(DropCounter);
} // vec dropped here
assert_eq!(DROP_COUNT.load(Ord::SeqCst), 3);
}
#[test]
fn test_thread_safety() {
use std::sync::Arc;
use std::thread;
let vec = Arc::new(SpinLock::new(MyVec::new()));
let mut handles = vec![];
for i in 0..10 {
let vec_clone = Arc::clone(&vec);
handles.push(thread::spawn(move || {
vec_clone.lock().push(i);
}));
}
for handle in handles {
handle.join().unwrap();
}
assert_eq!(vec.lock().len(), 10);
}
}
}
Testing strategies:
- Basic correctness: Does it work for normal cases?
- Edge cases: Empty, single element, capacity boundaries
- Drop tracking: Use
DropCounterfor leak/double-drop check - Thread safety: Test concurrent access with multiple threads
- Miri: Run tests with Miri to detect UB
Miri is essential: It interprets your code at the MIR level and detects undefined behavior that compiles fine but is wrong.
Pattern 5: Best Practices for Unsafe Code
Unsafe Rust is powerful but dangerous. These practices help you wield that power responsibly.
1. Minimize Unsafe Boundaries
Keep unsafe code localized. Encapsulate in small, well-tested functions.
#![allow(unused)]
fn main() {
// BAD: Unsafe spreads throughout the code
pub fn bad_api(data: *mut u8, len: usize) {
// Users must pass raw pointers
}
// GOOD: Unsafe is contained internally
pub fn good_api(data: &mut [u8]) {
unsafe {
// Unsafe code is hidden from users
}
}
}
Principle: Unsafe should be an implementation detail, not a user-facing requirement.
2. Document Safety Requirements
Every unsafe function must document its preconditions. This is for your future self and other maintainers.
#![allow(unused)]
fn main() {
/// Interprets a raw pointer as a slice.
/// # Safety: The caller must ensure that:
/// - `ptr` valid for reads of `len * size_of::<T>()` bytes
/// - `ptr` is properly aligned for type `T`
/// - Memory at `ptr` is not concurrently written
/// - `len` is exactly the element count in allocation
/// - The memory contains valid values of type `T`
pub unsafe fn from_raw_parts<T>(
ptr: *const T,
len: usize
) -> &'static [T] {
std::slice::from_raw_parts(ptr, len)
}
}
What to document:
- Pointer validity (aligned, non-null, within bounds)
- Initialization state (is memory initialized?)
- Concurrent access (can other threads access this?)
- Ownership (who frees this memory?)
3. Use Helper Functions
Extract unsafe operations into well-named, well-tested helper functions.
#![allow(unused)]
fn main() {
mod unsafe_helpers {
/// Writes value to ptr without dropping old value.
/// # Safety
/// `ptr` must be valid for writes and properly aligned.
pub(crate) unsafe fn write_unchecked<T>(
ptr: *mut T,
value: T
) {
debug_assert!(!ptr.is_null(), "null pointer");
std::ptr::write(ptr, value);
}
/// Reads a value from a pointer without moving it.
/// # Safety
/// `ptr` must be valid, aligned, point to init data.
pub(crate) unsafe fn read_unchecked<T>(ptr: *const T) -> T {
debug_assert!(!ptr.is_null(), "null pointer");
std::ptr::read(ptr)
}
}
}
Benefits:
- Centralize unsafe operations for easier auditing
- Add debug assertions for development builds
- Document assumptions once, reference from call sites
4. Use Clippy and Miri
Automated tools catch mistakes humans miss.
# Check for undocumented unsafe blocks
cargo clippy -- -W clippy::undocumented_unsafe_blocks
# Detect undefined behavior at runtime
cargo +nightly miri test
# Run with address sanitizer (detects memory errors)
RUSTFLAGS="-Z sanitizer=address" cargo +nightly test
# Run with thread sanitizer (detects data races)
RUSTFLAGS="-Z sanitizer=thread" cargo +nightly test
Miri interprets code and detects:
- Use of uninitialized memory
- Use-after-free
- Invalid pointer arithmetic
- Data races (in unsafe code)
- Violating pointer aliasing rules
Clippy warns about:
- Undocumented unsafe blocks
- Transmutes that could be replaced with safe alternatives
- Missing safety comments
5. Consider Alternatives
Before writing unsafe code, check if a safe solution exists.
#![allow(unused)]
fn main() {
//===================================
// Instead of raw pointers, consider:
//===================================
// - std::pin::Pin for self-referential structs
// - std::rc::Rc or std::sync::Arc for shared ownership
// - std::cell::UnsafeCell for interior mutability
// - std::sync::atomic for lock-free operations
// - ouroboros crate for self-referential structs
// - bytemuck crate for safe transmutes
// - zerocopy crate for zero-copy parsing with safety
//======================================
// Example: Safe transmute with bytemuck
//======================================
use bytemuck::{Pod, Zeroable};
#[derive(Copy, Clone, Pod, Zeroable)]
#[repr(C)]
struct Point {
x: f32,
y: f32,
}
fn safe_transmute_example() {
let bytes: [u8; 8] = [0; 8];
let point: Point = bytemuck::cast(bytes); // Safe
}
}
Crates that help:
bytemuck: Safe transmutations with compile-time verificationzerocopy: Zero-copy parsing with safety proofsouroboros: Self-referential structs without unsafe (macros generate safe wrappers)parking_lot: Better locks with less overhead thanstd::sync
Summary
Unsafe Rust is not about being reckless—it’s about taking responsibility for safety properties the compiler cannot verify.
Key principles:
- Minimize scope: Keep unsafe blocks small and localized
- Build safe abstractions: Encapsulate unsafe code behind safe APIs
- Document invariants: Explain what must be true for correctness
- Test rigorously: Use Miri, sanitizers, and stress tests
- Prefer alternatives: Use safe solutions when they exist
When to use unsafe:
- Implementing fundamental abstractions (collections, concurrency primitives)
- FFI to interact with C libraries
- Performance optimizations where safe code can’t match (after profiling!)
- Hardware-level programming (embedded systems, drivers)
When to avoid unsafe:
- To bypass the borrow checker in application code (redesign instead)
- For micro-optimizations without measurements
- When safe abstractions exist (use them!)
- If you can’t explain why it’s safe (it probably isn’t)
The standard library is proof this works: millions of lines of safe Rust code rely on carefully crafted unsafe foundations. Your unsafe code can achieve the same reliability with discipline and care.