C developers may first read unsafe as “the place where Rust lets me write C.”
That is too broad.
unsafe does not disable all Rust checks, and it does not turn a block back into C. It permits a small set of operations the compiler cannot prove safe, such as dereferencing raw pointers, calling FFI functions, and accessing mutable global state.
The key question is not whether unsafe can be written. It is how small the unsafe boundary can be.
C Is Unsafe by Default
C code often relies on programmer-provided preconditions:
int read_byte(const uint8_t *ptr)
{
return ptr[0];
}
The caller must guarantee:
ptris notNULLptrpoints to at least one readable byte- the memory stays valid during access
- this access does not race with another write
C types cannot fully express these conditions.
Safe Rust does not allow direct raw pointer dereference. It requires unsafe:
unsafe fn read_byte(ptr: *const u8) -> u8 {
unsafe { *ptr }
}
The dereference is not always wrong. The compiler simply cannot prove ptr is valid.
unsafe Permits a Few Extra Operations
unsafe mainly permits:
- dereferencing raw pointers
- calling
unsafe fn - accessing or modifying
static mut - implementing
unsafe trait - accessing union fields
Normal Rust rules still apply. Type checking, ownership, borrowing, matching, and error handling remain active.
Example:
unsafe fn read_byte(ptr: *const u8) -> u8 {
if ptr.is_null() {
return 0;
}
unsafe { *ptr }
}
The if, return, and type checks are normal Rust. Only *ptr needs unsafe because it depends on extra assumptions: even if the pointer is not null, it must still be readable, aligned, and point to a valid object.
unsafe fn Means the Caller Has Preconditions
If a function requires callers to uphold safety conditions, mark it unsafe fn:
unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
unsafe { std::slice::from_raw_parts(ptr, len) }
}
The caller must guarantee:
ptrpoints tolenvalid bytes- memory remains valid while the returned slice is used
- memory is not mutably accessed at the same time
lenmatches the accessible range
These preconditions should be documented, usually in a # Safety section.
unsafe fn does not mean the function is automatically dangerous. It means calling it safely requires extra conditions the caller must satisfy.
Safe Wrappers Keep Preconditions Inside
The better pattern is usually to keep unsafe small and wrap it behind a safe API.
For example:
fn first_byte(data: &[u8]) -> Option<u8> {
data.first().copied()
}
This needs no unsafe because a slice already preserves the pointer-length relationship.
If entry must come from raw pointers:
unsafe fn first_byte_from_raw(ptr: *const u8, len: usize) -> Option<u8> {
if ptr.is_null() || len == 0 {
return None;
}
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
data.first().copied()
}
Unsafe appears only when converting the raw pointer into a slice. After that, use safe Rust.
Common strategy:
raw pointer / FFI / hardware boundary -> small unsafe block -> safe Rust type
FFI Commonly Requires unsafe
Calling C functions usually requires unsafe:
unsafe extern "C" {
fn strlen(s: *const std::os::raw::c_char) -> usize;
}
fn len_from_c_string(s: &std::ffi::CStr) -> usize {
unsafe { strlen(s.as_ptr()) }
}
C functions do not know Rust borrowing or lifetimes. Rust cannot prove a C function will not store a pointer, go out of bounds, or violate thread safety.
FFI boundaries need explicit rules:
- whether pointers may be null
- whether strings are NUL-terminated
- who releases memory
- whether C stores pointers
- which thread calls callbacks
- how errors are returned
unsafe tells you that Rust cannot verify the preconditions. Actual safety comes from boundary design.
unsafe Should Not Spread
A poor pattern is placing too much business logic inside unsafe:
unsafe fn parse_and_handle(ptr: *const u8, len: usize) {
// parse protocol
// update state
// call callbacks
// manage resources
}
A better pattern converts the boundary first:
unsafe fn parse_from_raw(ptr: *const u8, len: usize) -> Result<(), ParseError> {
if ptr.is_null() {
return Err(ParseError::Null);
}
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
parse_packet(data)
}
fn parse_packet(data: &[u8]) -> Result<(), ParseError> {
// safe parsing logic
Ok(())
}
Unsafe only handles entry from an unverifiable external world. Parsing remains safe Rust.
What to Keep from This Article
The first mapping is:
raw pointer dereference -> unsafe
C function call -> unsafe extern / unsafe call
void * + len -> unsafe conversion to &[u8], then safe logic
mutable global state -> static mut / unsafe, prefer avoiding
docs say caller guarantees -> unsafe fn Safety preconditions
safe wrapper -> small unsafe boundary + safe Rust API
unsafe does not mean “Rust rules do not apply here.”
It means there are assumptions the compiler cannot prove and the programmer must uphold. In engineering terms, keep unsafe small, close to FFI or raw pointer boundaries, and return to safe Rust types quickly.
The next article can cover FFI: C ABI, exported symbols, strings, memory ownership, and error returns.