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.
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:
- Dereference a raw pointer (
*const Tor*mut T) - Call an unsafe function or method
- Access or modify a mutable static variable
- Implement an unsafe trait
- 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.
Related articles
- Rust Rust Pin and Unpin Explained: Why They Exist
Understand Pin, Unpin, and self-referential types in Rust. Learn why Pin exists, when you need it, and how to work with pinned data safely.
- Rust Rust Async Programming with Tokio: Getting Started
Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.
- Rust Rust Concurrency: Channels, Mutex, and Arc Patterns
Learn Rust concurrency with channels, Mutex, Arc, and thread-safe patterns. Covers mpsc, shared state, thread pools, and real-world examples.
- Rust Rust Error Handling with anyhow and thiserror
Master Rust error handling with anyhow for applications and thiserror for libraries. Practical patterns, conversions, and context-rich error reporting.