Unsafe Rust
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in BCP 14 RFC 2119 RFC 8174 when, and only when, they appear in all capitals, as shown here.
This guide covers when unsafe is justified, the invariants every unsafe block
MUST document, Edition 2024 rules for extern and unsafe attributes, FFI,
and checking with Miri. The toolchain, lint, formatting, and pre-commit rules in
the Rust Style Guide apply and are not repeated here, and so do
the dependency and CI rules; every version quoted is
listed in the Tested Version Matrix and was
current on 8 September 2026.
Projects SHOULD minimize usage of unsafe. When unsafe is required, it
MUST be carefully documented and isolated.
Why Minimize Unsafe
- Compiler guarantees: Safe Rust provides memory safety guarantees that
unsafebypasses - Bug surface: Unsafe code is where memory bugs hide
- Review burden: Unsafe code requires more careful review
- Soundness: Incorrect unsafe code can cause undefined behavior
When Unsafe is Necessary
| Use Case | Example |
|---|---|
| FFI (Foreign Function Interface) | Calling C libraries |
| Raw pointer manipulation | Custom data structures |
| Performance-critical code | SIMD, avoiding bounds checks |
| Hardware access | Memory-mapped I/O |
| Implementing low-level abstractions | Arc, Mutex internals |
Unsafe Best Practices
A function that cannot uphold an unsafe operation’s preconditions itself
MUST be declared unsafe fn with a # Safety section. A // SAFETY:
comment MUST NOT be used to push an obligation onto callers of a safe
function.
Why: safety is part of the signature, not of a comment. A safe
get_unchecked can be called from entirely safe code with any index; under
Miri, get_unchecked(&[], 0) aborts with “unsafe precondition(s) violated:
slice::get_unchecked requires that the index is within the slice”.1
// GOOD: bounds-checked, safe to call with any index
#[must_use]
pub fn get(slice: &[u8], index: usize) -> Option<u8> {
slice.get(index).copied()
}
// GOOD: the obligation is in the signature and documented
/// Reads `slice[index]` without a bounds check.
///
/// # Safety
///
/// `index` must be strictly less than `slice.len()`. Violating this reads out
/// of bounds, which is undefined behavior.
#[must_use]
pub unsafe fn get_unchecked(slice: &[u8], index: usize) -> u8 {
debug_assert!(index < slice.len());
// SAFETY: the caller guarantees `index < slice.len()`.
unsafe { *slice.get_unchecked(index) }
}
// BAD: safe fn, unchecked index. The comment transfers nothing; safe callers
// can pass any index and cause undefined behavior.
pub fn get_unchecked(slice: &[u8], index: usize) -> u8 {
// SAFETY: Caller must ensure index < slice.len()
unsafe { *slice.get_unchecked(index) }
}
An abstraction over a raw allocation MUST handle allocation failure,
initialize memory before it is read, and free the allocation in Drop.
Why: std::alloc::alloc returns uninitialized memory and a null pointer on
failure, and it never frees anything. Reading such a byte through a safe method
is undefined behavior even when the allocation succeeds: on the SafeBuffer
shown below, Miri reports “Undefined Behavior: reading memory …, but memory is
uninitialized …, and this operation requires initialized memory” for
SafeBuffer::new(1).get(0).1
alloc_zeroed initializes the whole block, handle_alloc_error reports failure
without producing a dangling NonNull, and Drop returns the memory.
// GOOD: Encapsulate unsafe in a safe API with a complete set of invariants
use std::alloc::{Layout, alloc_zeroed, dealloc, handle_alloc_error};
use std::ptr::NonNull;
pub struct ZeroedBuffer {
ptr: NonNull<u8>,
layout: Layout,
}
impl ZeroedBuffer {
/// Allocates `len` zeroed bytes. Returns `None` for `len == 0`, which is
/// not a valid allocation size.
#[must_use]
pub fn new(len: usize) -> Option<Self> {
if len == 0 {
return None;
}
let layout = Layout::array::<u8>(len).ok()?;
// SAFETY: `layout` has non-zero size because `len > 0`.
let ptr = unsafe { alloc_zeroed(layout) };
// The allocator returns null on failure; it must never be
// dereferenced or wrapped in `NonNull`.
let Some(ptr) = NonNull::new(ptr) else {
handle_alloc_error(layout)
};
Some(Self { ptr, layout })
}
#[must_use]
pub const fn len(&self) -> usize {
self.layout.size()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
false
}
#[must_use]
pub const fn get(&self, index: usize) -> Option<u8> {
if index >= self.len() {
return None;
}
// SAFETY: `index < self.len()`, so the offset stays inside the
// allocation, and `alloc_zeroed` initialized every byte of it.
Some(unsafe { self.ptr.as_ptr().add(index).read() })
}
pub const fn set(&mut self, index: usize, value: u8) -> bool {
if index >= self.len() {
return false;
}
// SAFETY: `index < self.len()` and `&mut self` rules out aliasing.
unsafe { self.ptr.as_ptr().add(index).write(value) };
true
}
}
impl Drop for ZeroedBuffer {
fn drop(&mut self) {
// SAFETY: `ptr` came from `alloc_zeroed` with exactly `self.layout`
// and has not been freed; `Drop` runs at most once.
unsafe { dealloc(self.ptr.as_ptr(), self.layout) };
}
}
// BAD: unwraps the layout, ignores allocation failure, reads uninitialized
// bytes through a safe method, and leaks the allocation.
pub struct SafeBuffer {
ptr: *mut u8,
len: usize,
}
impl SafeBuffer {
pub fn new(size: usize) -> Self {
let ptr = unsafe {
// SAFETY: size is non-zero, layout is valid <- neither is checked
std::alloc::alloc(std::alloc::Layout::array::<u8>(size).unwrap())
};
Self { ptr, len: size }
}
pub fn get(&self, index: usize) -> Option<u8> {
if index < self.len {
// SAFETY: index is bounds-checked <- but the byte is uninitialized
Some(unsafe { *self.ptr.add(index) })
} else {
None
}
}
}
// No `Drop`: every SafeBuffer leaks its allocation.
Prefer a safe owner where one exists. vec![0u8; len] gives the same
guarantees with no unsafe at all; reach for raw allocation only when the
layout or ownership cannot be expressed with Vec or Box.
// GOOD: no unsafe needed for a zeroed byte buffer
let buffer = vec![0u8; 100];
assert_eq!(buffer.get(100), None);
// GOOD: Document invariants
/// A non-null pointer to a valid T.
///
/// # Safety
///
/// The pointer must:
/// - Be properly aligned for T
/// - Point to a valid, initialized T
/// - Not be aliased by any mutable reference
pub struct NonNullPtr<T> {
ptr: *const T,
}
SAFETY Comments
All unsafe blocks MUST include a // SAFETY: comment explaining why the code is sound:
// GOOD: Explains why this is safe
let value = unsafe {
// SAFETY: We verified ptr is non-null and properly aligned above.
// The lifetime is tied to 'a, ensuring the reference remains valid.
&*ptr
};
// BAD: No safety justification
let value = unsafe { &*ptr };
// BAD: Insufficient explanation
let value = unsafe {
// SAFETY: It's fine
&*ptr
};
FFI Guidelines
In Edition 2024 an extern block is itself unsafe and MUST be written
unsafe extern "C". Attributes that affect linkage — no_mangle,
link_section, export_name — MUST be written in the unsafe(...) form.
Why: nothing checks that a declaration matches the library actually linked.
Declaring fn strlen(s: *const c_char) -> usize with the wrong signature is
undefined behavior at every call site, and before Edition 2024 the declaration
itself needed no unsafe. Plain extern "C" { ... } in an Edition 2024 crate
fails with “error: extern blocks must be unsafe”, and #[no_mangle] with
“error: unsafe attribute used without unsafe”.2
A wrapper MUST NOT hide a failure mode behind expect. CString::new
fails on an interior null byte, which is ordinary caller input.
use std::ffi::{CStr, CString, NulError};
use std::os::raw::c_char;
// Edition 2024: the extern block is unsafe, because nothing checks that these
// declarations match the library that is linked in.
unsafe extern "C" {
fn strlen(s: *const c_char) -> usize;
fn malloc(size: usize) -> *mut u8;
fn free(ptr: *mut u8);
}
// GOOD: safe wrapper; the interior-null case is a Result, not a panic.
/// # Errors
///
/// Returns [`NulError`] when `s` contains an interior null byte and therefore
/// has no C string representation.
pub fn safe_strlen(s: &str) -> Result<usize, NulError> {
let c_string = CString::new(s)?;
// SAFETY: `c_string` is a valid, null-terminated C string that outlives
// the call.
Ok(unsafe { strlen(c_string.as_ptr()) })
}
// GOOD: RAII wrapper for C resources
pub struct CBuffer {
ptr: *mut u8,
len: usize,
}
impl CBuffer {
pub fn new(size: usize) -> Option<Self> {
// SAFETY: malloc returns null on failure, checked below
let ptr = unsafe { malloc(size) };
if ptr.is_null() {
None
} else {
Some(Self { ptr, len: size })
}
}
}
impl Drop for CBuffer {
fn drop(&mut self) {
// SAFETY: ptr was allocated by malloc and has not been freed
unsafe { free(self.ptr) };
}
}
// GOOD: Edition 2024 form for a symbol exported to C
#[unsafe(no_mangle)]
pub extern "C" fn my_crate_version() -> u32 {
env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap_or(0)
}
// BAD: Edition 2024 rejects both of these.
// error: extern blocks must be unsafe
// error: unsafe attribute used without unsafe
extern "C" {
fn strlen(s: *const c_char) -> usize;
}
#[no_mangle]
pub extern "C" fn my_crate_version() -> u32 { 1 }
Testing Unsafe Code
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test-only allowance")]
mod tests {
use super::*;
#[test]
fn checked_access() {
let data = [1_u8, 2, 3];
assert_eq!(get(&data, 0), Some(1));
assert_eq!(get(&data, 3), None);
// SAFETY: 2 < data.len().
assert_eq!(unsafe { get_unchecked(&data, 2) }, 3);
}
#[test]
fn safe_wrapper() {
let buffer = ZeroedBuffer::new(100).expect("100 > 0");
assert_eq!(buffer.get(0), Some(0));
assert_eq!(buffer.get(99), Some(0));
assert_eq!(buffer.get(100), None);
}
#[test]
fn edge_cases() {
assert!(ZeroedBuffer::new(0).is_none());
let mut buffer = ZeroedBuffer::new(1).expect("1 > 0");
assert_eq!(buffer.get(0), Some(0));
assert!(buffer.set(0, 42));
assert_eq!(buffer.get(0), Some(42));
assert_eq!(buffer.get(1), None);
assert!(!buffer.set(1, 42));
}
#[test]
#[cfg_attr(miri, ignore = "calls into C")]
fn ffi_wrapper_reports_interior_nul() {
assert_eq!(safe_strlen("abc"), Ok(3));
assert!(safe_strlen("a\0b").is_err());
}
}
Tests over unsafe code MUST assert on values, not merely on
is_some(). assert!(buffer.get(0).is_some()) passes just as happily when the
byte behind it is uninitialized, so it cannot distinguish a sound
implementation from an unsound one; only Miri or a value assertion can.
Miri for Undefined Behavior Detection
Projects with unsafe code SHOULD run Miri1 to detect undefined
behavior. The nightly toolchain MUST be pinned by date, exactly as for
fuzzing.
Why: Miri ships only on nightly and is occasionally missing from a given
night’s build, so an unpinned +nightly job fails intermittently for reasons
unrelated to the code. rustup component add miri --toolchain <date> fails
loudly when the component is absent, which is the failure you want.
rustup toolchain install nightly-2026-09-01 --component miri
# Run the whole suite under Miri
cargo +nightly-2026-09-01 miri test
# Run one test
cargo +nightly-2026-09-01 miri test safe_wrapper
# Stricter aliasing model, and check for leaks
MIRIFLAGS="-Zmiri-tree-borrows -Zmiri-strict-provenance" \
cargo +nightly-2026-09-01 miri test
Miri interprets Rust; it MUST NOT be expected to run tests that call into
C. A test that reaches an extern "C" function aborts with “unsupported
operation: can’t call foreign function”. FFI wrappers SHOULD therefore be
tested under Miri with the foreign call behind #[cfg(not(miri))], leaving the
pure-Rust invariants covered.
#[test]
#[cfg_attr(miri, ignore = "calls into C")]
fn ffi_wrapper_reports_interior_nul() {
assert_eq!(safe_strlen("abc"), Ok(3));
}
Miri is slow — typically 10× to 100× native — so it SHOULD run on a
schedule or on changes to unsafe code rather than on every push.
Unsafe Antipatterns
// BAD: Unnecessary unsafe
let x: i32 = unsafe { 5 }; // Nothing unsafe here!
// BAD: Unsafe impl without necessity
unsafe impl Send for MyType {} // Only if truly needed
// BAD: Transmute for type conversion
let x: u32 = unsafe { std::mem::transmute(1.0f32) };
// GOOD: Use to_bits() instead
let x: u32 = 1.0f32.to_bits();
// BAD: Unchecked indexing without bounds verification
let value = unsafe { *slice.get_unchecked(user_input) };
// GOOD: Bounds check first
if user_input < slice.len() {
let value = unsafe { *slice.get_unchecked(user_input) };
}
Unsafe Trait Implementations
// Manually implementing Send/Sync requires careful thought
struct MyWrapper<T> {
data: *mut T,
}
// SAFETY: MyWrapper can be sent across threads because:
// 1. The pointer is not shared (unique ownership)
// 2. T itself is Send
unsafe impl<T: Send> Send for MyWrapper<T> {}
// SAFETY: MyWrapper can be shared across threads because:
// 1. All access to data is synchronized (not shown)
// 2. T itself is Sync
unsafe impl<T: Sync> Sync for MyWrapper<T> {}
References
See Also
- Rust Style Guide - Toolchain, lints, formatting, dependencies, and CI
- Rust topic guides - The other Rust topic guides
Footnotes
-
Miri - Experimental interpreter for Rust’s mid-level intermediate representation ↩ ↩2 ↩3
-
Announcing Rust 1.85.0 and Rust 2024 - Edition 2024 stabilization, 20 February 2025; see the Edition Guide ↩