Unsafe Rust: concepts, trade-offs and production lessons
Rust's safety guarantees are its superpower, but sometimes you need to step outside them. Whether you're binding to C libraries, optimizing a hot path, or implementing data structures that the borrow checker can't reason about, unsafe Rust gives you that escape hatch—but it comes with responsibility. In this post, we'll explore what unsafe allows, when it makes sense, and how we've learned to use it responsibly in production systems.
What unsafe allows — Raw pointers, FFI and unchecked operations
Unsafe Rust unlocks five core capabilities that the safe language forbids:
- Dereferencing raw pointers
- Calling unsafe functions or extern functions
- Accessing or modifying mutable static variables
- Implementing unsafe traits
- Accessing union fields
The keyword unsafe doesn't disable the compiler's checks entirely—it just tells Rust "I'm taking responsibility for these invariants." The compiler still enforces memory safety rules everywhere else in your code.
The most common use case we encounter is calling C libraries through FFI (Foreign Function Interface). Imagine wrapping a high-performance compression library:
extern "C" {
fn compress_buffer(
input: *const u8,
input_len: usize,
output: *mut u8,
output_len: *mut usize,
) -> i32;
}
pub fn compress(data: &[u8]) -> Result, String> {
let mut output = vec![0u8; data.len() * 2];
let mut output_len = output.len();
let status = unsafe {
compress_buffer(
data.as_ptr(),
data.len(),
output.as_mut_ptr(),
&mut output_len,
)
};
if status != 0 {
return Err("Compression failed".to_string());
}
output.truncate(output_len);
Ok(output)
}This unsafe block is safe because we've verified that the C function's contract is satisfied: valid pointers, correct lengths, and proper alignment. The unsafe code is quarantined and wrapped in a safe API.
Raw pointers also show up when implementing intrusive data structures—like lock-free queues or custom allocators. You need them when the borrow checker can't prove something is safe even though you know it is:
pub struct Node {
value: T,
next: Option>>,
}
impl Node {
pub fn iter_mut(&mut self) -> impl Iterator {
std::iter::once(&mut self.value).chain(
self.next.as_mut().map(|n| n.iter_mut()).into_iter().flatten()
)
}
}That example stays safe, but if you're building a skiplist or a concurrent structure where nodes point to each other in cycles, you might need raw pointers and unsafe code to break the rules temporarily—then re-establish invariants that the type system validates.
Keep it unsafe region small — Wrap unsafe code in safe APIs
The most critical production lesson we've learned: unsafe code should be a small island, surrounded by a sea of safe code.
Never let unsafe code leak into your public API. Instead, create a safe wrapper that encapsulates the risk and documents the contract. This has several benefits:
- Audit surface is small and focused
- Callers can't accidentally misuse the unsafe code
- Future maintainers understand the boundary clearly
- Tests can validate the safe wrapper's behavior without reasoning about unsafety
Here's an example from a real production optimization—a thread-local cache that needed to bypass Rust's borrow rules for performance:
use std::cell::UnsafeCell;
pub struct ThreadLocalCache {
data: UnsafeCell>,
}
impl ThreadLocalCache {
pub fn new() -> Self {
ThreadLocalCache {
data: UnsafeCell::new(Vec::new()),
}
}
pub fn insert(&self, item: T) {
unsafe {
(*self.data.get()).push(item);
}
}
pub fn get_all(&self) -> Vec
where
T: Clone,
{
unsafe {
(*self.data.get()).clone()
}
}
}The unsafe code is confined to the impl block. Callers never touch UnsafeCell directly. The public API is safe because we control all access paths and ensure they maintain the invariant (no aliased mutable borrows).
A useful pattern is to use newtype wrappers around raw pointers to make the intent explicit and prevent misuse:
pub struct OpaqueHandle(*const c_void);
impl OpaqueHandle {
pub fn new(val: &T) -> Self {
OpaqueHandle(val as *const T as *const c_void)
}
pub fn as_ref<'a, T: 'static>(&self) -> Option<&'a T> {
if self.0.is_null() {
None
} else {
unsafe { Some(&*(self.0 as *const T)) }
}
}
}This approach localizes the risk and makes it obvious which operations involve pointer casts.
Document invariants — Explain why the code is sound
When you write unsafe code, future you (or a colleague in six months) needs to understand why it's actually safe, not just that you asserted it was.
Every unsafe block should have a comment explaining the invariants it relies on. Be specific about what assumptions hold and why dereferencing that pointer or calling that function is legal:
pub fn slice_from_ptr(ptr: *const T, len: usize) -> &'static [T] {
// SAFETY: Caller guarantees:
// - ptr is valid and aligned for T
// - ptr points to at least len valid instances of T
// - the memory pointed to is not mutated for the lifetime of the slice
// - ptr was allocated with the global allocator or is static data
unsafe { std::slice::from_raw_parts(ptr, len) }
}This makes it easy for a reviewer to check whether the preconditions are actually met at the call site. If the preconditions aren't documented, unsafe code becomes a liability during refactoring.
We've found that strict documentation practices catch bugs before code reaches production. A colleague reviewing your PR can spot when a caller isn't actually satisfying the invariant—much better than finding out via a segfault in production.
In addition to inline comments, consider creating a module-level comment in files with significant unsafe sections:
// # Safety notes for this module
//
// This module wraps the libfoo C API. Key invariants:
// - All *const FooHandle pointers must outlive the operation using them
// - FooHandle is not Send; operations must happen on a single thread
// - Calling foo_cleanup invalidates all handles; no further operations are safe
Most apps never need it — Libraries handle it for you
Here's the practical reality: if you're building a CRUD API, an e-commerce backend, or a SaaS application, you almost certainly don't need to write unsafe code yourself.
The Rust ecosystem is mature enough that almost every performance-critical operation has a battle-tested library that handled the unsafe complexity for you:
- Serialization: serde, bincode (unsafe internals hidden)
- Compression: flate2, zstd (wrap C libraries safely)
- Concurrency: crossbeam, parking_lot (lock-free data structures)
- Numeric code: ndarray, nalgebra (SIMD operations)
- Database drivers: sqlx, tokio-postgres (async I/O over raw sockets)
Reaching for unsafe too early is a sign that either (a) a library exists and you haven't found it, or (b) you're optimizing prematurely. Profile first. Measure the impact. Most bottlenecks in production are algorithmic, not because Rust is "too safe."
We only encountered genuine unsafe requirements after years of production work: FFI bindings to specialized C libraries, implementing custom allocators for extreme latency requirements, and building lock-free data structures for trading systems where microseconds matter. Even then, we isolated the unsafe code to a single crate that the team knew intimately.
Common mistakes
- Forgetting that safe Rust can still panic:
unwrap()and array indexing can crash just as hard as unsafe code. Don't trade one form of unsafety for another. - Assuming unsafe means faster: Unsafe code that's harder to optimize can actually be slower. The compiler's safety checks often help it vectorize and reason about code. Measure before and after.
- Mixing unsafe with dynamic lifetimes: If you're using unsafe pointers, keep them as short-lived and localized as possible. Passing raw pointers through many layers is a recipe for use-after-free bugs.
- Not testing the boundary: Write tests that exercise the safe wrapper and intentionally violate its preconditions. Ensure that misuse is caught, not silently accepted.
- Documenting too little or too much: Don't document every line. Instead, focus on why the invariants hold and what assumptions the caller must satisfy. Keep comments concise but complete.
Key takeaways
- Unsafe Rust allows you to bypass the compiler's safety checks, but you're responsible for maintaining correctness. Use it for FFI, low-level algorithms, and performance-critical code only.
- Always wrap unsafe code in safe abstractions. Never expose raw pointers or unsafe operations in your public API.
- Document the invariants that make unsafe code sound. Comment why pointer dereferences, FFI calls, and unchecked operations are actually safe.
- Most applications don't need unsafe. Reach for it only after profiling shows a genuine bottleneck and you've verified that no library solves the problem.
- Keep unsafe regions small, auditable, and close to the boundary where you need to break the rules. A 5-line unsafe block surrounded by hundreds of safe code is far easier to maintain than scattered unsafe throughout your codebase.






Comments
Loading comments…