Rust’s safety guarantees are enforced at compile time — and for the vast majority of code, the compiler can verify everything. But some tasks require stepping outside those guarantees: calling C libraries, writing memory allocators, building OS kernels, or implementing data structures that the borrow checker can’t reason about. For these cases, Rust provides unsafe.
unsafe doesn’t disable the borrow checker or turn Rust into C. It grants access to five specific capabilities that require human verification. The compiler can’t check them, so you take responsibility.
The Five Unsafe Superpowers
Only these operations require unsafe:
- Dereference a raw pointer (
*const Tor*mut T) - Call an
unsafefunction or method - Access or modify a
static mutvariable - Implement an
unsafetrait - Access fields of a
union
Everything else — including creating raw pointers, calling safe functions, and most standard library code — is still safe even inside an unsafe block.
1. Raw Pointers
Raw pointers are Rust’s equivalent of C pointers. They can be null, misaligned, or point to freed memory. Creating them is safe; dereferencing them requires unsafe.
fn main() {
let mut x = 42i32;
// Creating raw pointers — always safe
let ptr_const: *const i32 = &x;
let ptr_mut: *mut i32 = &mut x;
// Dereferencing — requires unsafe
unsafe {
println!("Value: {}", *ptr_const);
*ptr_mut = 100;
println!("After write: {}", *ptr_const);
}
// Null pointer
let null_ptr: *const i32 = std::ptr::null();
unsafe {
if !null_ptr.is_null() {
println!("{}", *null_ptr); // Never reached
}
}
}
Raw Pointer Arithmetic
Pointer arithmetic is the foundation of many low-level algorithms:
fn main() {
let arr = [10i32, 20, 30, 40, 50];
let ptr: *const i32 = arr.as_ptr();
unsafe {
for i in 0..arr.len() {
let val = *ptr.add(i); // ptr + i * sizeof(i32)
print!("{} ", val); // 10 20 30 40 50
}
println!();
// Offset between two pointers
let first = ptr;
let third = ptr.add(2);
let offset = third.offset_from(first);
println!("offset: {}", offset); // 2
}
}
Building a Safe Abstraction over Raw Pointers
The pattern: unsafe internals wrapped in a safe public API. Users of your API get safety; you take responsibility for the invariants:
/// A fixed-size ring buffer backed by raw memory.
pub struct RingBuffer<T> {
ptr: *mut T,
capacity: usize,
head: usize,
len: usize,
}
impl<T> RingBuffer<T> {
pub fn new(capacity: usize) -> Self {
let layout = std::alloc::Layout::array::<T>(capacity).unwrap();
let ptr = unsafe { std::alloc::alloc(layout) as *mut T };
assert!(!ptr.is_null(), "Allocation failed");
RingBuffer { ptr, capacity, head: 0, len: 0 }
}
pub fn push(&mut self, value: T) -> bool {
if self.len == self.capacity {
return false; // Full
}
let tail = (self.head + self.len) % self.capacity;
unsafe {
std::ptr::write(self.ptr.add(tail), value);
}
self.len += 1;
true
}
pub fn pop(&mut self) -> Option<T> {
if self.len == 0 {
return None;
}
let value = unsafe { std::ptr::read(self.ptr.add(self.head)) };
self.head = (self.head + 1) % self.capacity;
self.len -= 1;
Some(value)
}
pub fn len(&self) -> usize { self.len }
}
impl<T> Drop for RingBuffer<T> {
fn drop(&mut self) {
// Drop remaining elements
while self.pop().is_some() {}
let layout = std::alloc::Layout::array::<T>(self.capacity).unwrap();
unsafe { std::alloc::dealloc(self.ptr as *mut u8, layout); }
}
}
fn main() {
let mut buf: RingBuffer<String> = RingBuffer::new(3);
buf.push("a".to_string());
buf.push("b".to_string());
buf.push("c".to_string());
println!("full: {}", !buf.push("d".to_string())); // true
while let Some(val) = buf.pop() {
println!("{}", val); // a, b, c
}
}
2. Calling Unsafe Functions
A function marked unsafe fn has preconditions that the caller must guarantee:
// This function is unsafe because:
// - `ptr` must be non-null and properly aligned
// - The memory it points to must be valid for `len` elements of type T
// - The caller must guarantee the slice outlives the returned reference
unsafe fn slice_from_raw<'a, T>(ptr: *const T, len: usize) -> &'a [T] {
std::slice::from_raw_parts(ptr, len)
}
fn main() {
let data = vec![1u8, 2, 3, 4, 5];
unsafe {
// Safe to call because we know data is valid
let slice = slice_from_raw(data.as_ptr(), data.len());
println!("{:?}", slice); // [1, 2, 3, 4, 5]
}
}
Foreign Function Interface (FFI)
Calling C code is the most common reason to use unsafe. All C function calls are inherently unsafe because Rust can’t verify C’s safety guarantees:
// Link against the C standard library
extern "C" {
fn strlen(s: *const std::os::raw::c_char) -> usize;
fn abs(n: std::os::raw::c_int) -> std::os::raw::c_int;
}
fn main() {
unsafe {
let s = b"Hello, Rust!\0"; // null-terminated C string
let len = strlen(s.as_ptr() as *const std::os::raw::c_char);
println!("strlen: {}", len); // 12
println!("abs(-42) = {}", abs(-42)); // 42
}
}
A complete FFI example with safe wrapper:
// Cargo.toml: [build-dependencies] cc = "1"
// build.rs: cc::Build::new().file("src/mylib.c").compile("mylib");
extern "C" {
fn fast_sum(data: *const i32, len: usize) -> i64;
}
/// Safe wrapper around the C `fast_sum` function.
pub fn sum_with_c(data: &[i32]) -> i64 {
if data.is_empty() { return 0; }
unsafe { fast_sum(data.as_ptr(), data.len()) }
}
3. static mut — Global Mutable State
Global mutable state is unsafe because concurrent access creates data races:
static mut REQUEST_COUNT: u64 = 0;
static mut LAST_ERROR: Option<String> = None;
fn record_request() {
unsafe {
REQUEST_COUNT += 1;
}
}
fn set_error(msg: &str) {
unsafe {
LAST_ERROR = Some(msg.to_string());
}
}
fn get_count() -> u64 {
unsafe { REQUEST_COUNT }
}
Prefer safe alternatives in almost all cases:
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
// Atomic counter — safe, works across threads
static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0);
fn record_request() {
REQUEST_COUNT.fetch_add(1, Ordering::Relaxed);
}
// One-time initialized global — safe
static CONFIG: OnceLock<String> = OnceLock::new();
fn get_config() -> &'static str {
CONFIG.get_or_init(|| "default_config".to_string())
}
Use static mut only in embedded/no-std contexts where OnceLock and atomics aren’t available.
4. Implementing Unsafe Traits
Marking a trait unsafe declares that implementations must uphold invariants the compiler can’t verify. The two most important unsafe traits are Send and Sync:
Send: the type can be moved to another threadSync: the type can be shared between threads (i.e.,&TisSend)
The compiler automatically derives these for types whose fields are Send/Sync. You need manual unsafe impl only when wrapping raw pointers or other non-auto types:
// A wrapper around a raw pointer to shared memory
struct SharedBuffer {
ptr: *mut u8,
len: usize,
}
// SAFETY: We guarantee that:
// - The pointer is valid for `len` bytes
// - No two threads write to the same location simultaneously
// - We manage the lifetime externally (e.g., backed by shared memory region)
unsafe impl Send for SharedBuffer {}
unsafe impl Sync for SharedBuffer {}
impl SharedBuffer {
/// SAFETY: `ptr` must remain valid for the lifetime of this SharedBuffer
/// and must be aligned to `u8`.
pub unsafe fn new(ptr: *mut u8, len: usize) -> Self {
SharedBuffer { ptr, len }
}
pub fn read(&self, offset: usize) -> u8 {
assert!(offset < self.len);
unsafe { *self.ptr.add(offset) }
}
}
Defining Your Own Unsafe Trait
/// SAFETY: implementors must guarantee that `as_bytes()` returns
/// a valid, aligned slice of exactly `Self::SIZE` bytes.
pub unsafe trait FixedBytes {
const SIZE: usize;
fn as_bytes(&self) -> &[u8];
}
#[derive(Copy, Clone)]
struct NetworkHeader {
version: u8,
length: u16,
checksum: u32,
}
unsafe impl FixedBytes for NetworkHeader {
const SIZE: usize = 7; // 1 + 2 + 4
fn as_bytes(&self) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
self as *const Self as *const u8,
Self::SIZE,
)
}
}
}
5. Union Fields
A union stores all variants in the same memory. Only one variant is valid at a time. Reading the wrong variant is undefined behavior:
#[repr(C)]
union FloatBits {
float_val: f32,
int_val: u32,
}
fn main() {
let mut fb = FloatBits { float_val: 1.0f32 };
unsafe {
// Bit-cast: interpret the float's bits as an integer
println!("1.0f32 as bits: 0x{:08X}", fb.int_val);
// 0x3F800000 (IEEE 754 representation of 1.0)
fb.int_val = 0xBF800000; // -1.0 in IEEE 754
println!("0xBF800000 as f32: {}", fb.float_val); // -1.0
}
}
Today, f32::from_bits(u32) and f32::to_bits() are the safe alternatives for bit-casting. Unions still appear in C interop via #[repr(C)] structs.
Best Practices for Unsafe Code
1. Minimize Unsafe Surface Area
Keep unsafe blocks as small as possible. Extract the unsafe part into a tiny function and wrap it:
// Bad: large unsafe block
fn process(data: &[u8]) {
unsafe {
// 50 lines of code, only 2 of which actually need unsafe
let ptr = data.as_ptr();
// ... lots of safe logic ...
let val = *ptr.add(5);
// ... more safe logic ...
}
}
// Good: minimal unsafe surface
fn process(data: &[u8]) {
let ptr = data.as_ptr();
// ... lots of safe logic ...
let val = unsafe { *ptr.add(5) }; // Single unsafe expression
// ... more safe logic ...
}
2. Document Safety Invariants
Every unsafe block should have a // SAFETY: comment explaining why it’s correct:
let val = unsafe {
// SAFETY: `offset` is checked to be < data.len() on line 42,
// so `ptr.add(offset)` is within the allocation.
*ptr.add(offset)
};
3. Provide Safe Wrappers
Unsafe implementation + safe public API is the standard pattern:
pub mod safe_api {
/// Returns a reference to the middle element of the slice.
/// Returns None if the slice is empty.
pub fn middle<T>(slice: &[T]) -> Option<&T> {
if slice.is_empty() {
return None;
}
// SAFETY: idx is always < slice.len() because we checked is_empty()
// and integer division truncates.
let idx = slice.len() / 2;
Some(unsafe { slice.get_unchecked(idx) })
}
}
fn main() {
let v = vec![1, 2, 3, 4, 5];
println!("{:?}", safe_api::middle(&v)); // Some(3)
println!("{:?}", safe_api::middle::<i32>(&[])); // None
}
4. Use Existing Abstractions
Before writing unsafe, check if the standard library or a crate already handles it safely:
| Instead of | Use |
|---|---|
ptr::read/write for copying |
std::ptr::copy_nonoverlapping or Vec::extend_from_slice |
| Manual bit cast | f32::from_bits, f64::to_bits, or bytemuck::cast |
static mut counter |
AtomicU64, AtomicUsize |
| Raw pointer to global | OnceLock, LazyLock |
| Manual memory layout | #[repr(C)] + memoffset::offset_of! |
Common Mistakes
// WRONG: raw pointer to temporary (dangling after statement ends)
let p: *const i32 = &42; // The `42` temporary is dropped immediately
// WRONG: returning reference to local
unsafe fn bad_ref() -> &'static i32 {
let x = 42;
&x // x is on the stack — will be freed when function returns
}
// WRONG: aliasing mutable and immutable raw pointers
let mut x = 5;
let r = &x as *const i32;
let m = &mut x as *mut i32;
unsafe {
*m = 10; // Modifying while r still exists — undefined behavior
println!("{}", *r);
}
Summary
unsafe is not a last resort — it’s a precision tool for the rare cases where the compiler needs human help. Used correctly:
- It’s isolated in small, well-documented blocks
- It’s wrapped in safe public APIs
- Every invariant is explicitly stated in comments
- It enables zero-overhead systems programming that would be impossible otherwise
The standard library is built on unsafe foundations — Vec, String, Arc, Mutex, and the entire memory allocator are all implemented using raw pointers and unsafe. The key is that those unsafe internals are hidden behind safe interfaces that the rest of the ecosystem can rely on.
Resources
- Rust Book: Unsafe Rust
- The Rustonomicon — the definitive guide to unsafe Rust
- Rust Reference: Unsafe blocks
- bytemuck crate — safe bit casting
Comments