Most application code should use safe Rust. Sometimes a library must access hardware, implement a low-level data structure, or call a function written in another language. Those boundaries require promises the compiler cannot fully verify.
unsafe identifies where those promises are checked by people and abstraction design. It does not disable the borrow checker, make invalid references acceptable, or turn a failed safety condition into a recoverable error. A violation can produce undefined behavior: the compiler no longer has to preserve any predictable meaning for the program.
Read testing first. This lesson runs only examples whose pointer validity can be explained. The goal is to recognize and contain a boundary, not to replace ordinary safe operations with pointer tricks.
What unsafe permits
Unsafe operations include dereferencing raw pointers, calling unsafe functions, accessing mutable statics, implementing unsafe traits, and reading union fields. The surrounding code still obeys type checking and ordinary borrowing rules.
Distinguish two uses of the keyword:
- An
unsafe fndeclares obligations the caller must uphold beyond what its parameter types express. - An
unsafeblock is where the implementation claims that an unsafe operation's obligations have been met.
In the Rust 2024 edition, put unsafe operations inside explicit unsafe blocks even within an unsafe function; the unsafe_op_in_unsafe_fn lint warns when this separation is missing. A # Safety documentation section states the caller's obligations; a SAFETY comment at a call site explains why this particular call meets them. See the Book's unsafe chapter and the edition guide.
Raw pointers do not carry a borrow's guarantees
*const T and *mut T are raw pointer types. They may be null or dangling, and do not automatically keep an allocation alive. Creating or copying such a pointer is generally safe; accessing memory through it requires the appropriate safety conditions.
Create a scratch project:
cargo new boundary-lab --edition 2024
cd boundary-lab
This complete src/main.rs reads a valid pointer:
fn main() {
let score = 42_u32;
let pointer = &raw const score;
// SAFETY: pointer comes from this live, initialized, aligned u32.
// No code mutates score while it is read.
let copied = unsafe { *pointer };
assert_eq!(copied, 42);
println!("score: {copied}");
}
Run cargo run; output is score: 42.
In ordinary code, copying score directly is simpler. This example only separates pointer creation from pointer access. A pointer copied out of the scope of score would not keep it alive. Checking is_null() would detect null, but would not detect a dangling, unaligned, too-short or otherwise invalid allocation.
Write the contract before wrapping it
Suppose a foreign API gives an address and a byte length. Before creating a Rust slice, you must establish the conditions required by slice::from_raw_parts: one allocation, valid initialized elements, appropriate alignment, a permitted lifetime, and no incompatible mutation. Even a zero-length slice needs a non-null aligned pointer if you call that constructor.
This complete replacement src/main.rs handles the null-and-empty case before making any slice:
use std::slice;
/// Sum bytes modulo 2^32.
///
/// # Safety
/// If len is nonzero, data must point to len initialized, readable bytes
/// in a single allocation, with len <= isize::MAX. That allocation must
/// remain live and must not be mutated for the duration of this call.
/// If len is zero, data may be null; no memory is accessed.
unsafe fn checksum_raw(data: *const u8, len: usize) -> u32 {
if len == 0 {
return 0;
}
// SAFETY: the caller promises the complete slice-construction contract.
let bytes = unsafe { slice::from_raw_parts(data, len) };
bytes
.iter()
.fold(0_u32, |sum, &byte| sum.wrapping_add(u32::from(byte)))
}
fn checksum(bytes: &[u8]) -> u32 {
// SAFETY: the shared slice supplies one live, initialized allocation,
// a valid length, and access that excludes mutation during this call.
unsafe { checksum_raw(bytes.as_ptr(), bytes.len()) }
}
fn main() {
assert_eq!(checksum(&[1, 2, 250]), 253);
assert_eq!(checksum(&[]), 0);
// SAFETY: the documented zero-length case accesses no memory.
assert_eq!(unsafe { checksum_raw(std::ptr::null(), 0) }, 0);
println!("checksums verified");
}
Run cargo run; output is checksums verified.
The safe wrapper accepts a slice rather than arbitrary pointer bits. Its type establishes the obligations for every safe caller. It does not merely check for null and hope that the remaining memory is valid. The unsafe helper returns a number instead of inventing an unconstrained reference lifetime.
This checksum can and should be implemented directly over the input slice in normal Rust code. The raw helper illustrates a boundary you might have to consume; it does not improve the safe algorithm. Compare the contract with from_raw_parts before adapting it to another element type.
A complete C FFI example
FFI, or foreign function interface, is how code calls across a language boundary. Both sides must agree on the function's ABI, the calling convention and data representation used by compiled code.
Here C counts ASCII digits in a borrowed byte buffer. It never stores the pointer or writes through it. This example needs rustc, a native C compiler available as cc, and the archive tool ar. It is intended for a desktop native toolchain; cross-compilation and Windows toolchain-specific linking need their own configuration.
Make a separate empty directory named ffi-lab. Save this complete file as digits.c:
#include <stddef.h>
size_t count_ascii_digits(const unsigned char *data, size_t len) {
size_t count = 0;
for (size_t i = 0; i < len; ++i) {
if (data[i] >= 0x30 && data[i] <= 0x39) {
++count;
}
}
return count;
}
Save this complete Rust file as main.rs in the same directory:
use std::ffi::c_uchar;
unsafe extern "C" {
fn count_ascii_digits(data: *const c_uchar, len: usize) -> usize;
}
fn ascii_digit_count(bytes: &[u8]) -> usize {
// SAFETY: the declaration matches the C function on this native target.
// bytes supplies len readable bytes for the call; C neither mutates nor
// retains the pointer, performs no out-of-bounds access, and cannot unwind.
unsafe { count_ascii_digits(bytes.as_ptr().cast(), bytes.len()) }
}
fn main() {
assert_eq!(ascii_digit_count(b"room 42, floor 7"), 3);
assert_eq!(ascii_digit_count(b""), 0);
assert_eq!(ascii_digit_count(b"a\0b9"), 1);
assert_eq!(ascii_digit_count("9 and 7".as_bytes()), 1);
println!("ASCII digits: {}", ascii_digit_count(b"room 42, floor 7"));
}
#[cfg(test)]
mod tests {
use super::ascii_digit_count;
#[test]
fn counts_ascii_bytes_without_requiring_a_c_string() {
assert_eq!(ascii_digit_count(b"0123456789"), 10);
assert_eq!(ascii_digit_count(b"none"), 0);
assert_eq!(ascii_digit_count(b""), 0);
assert_eq!(ascii_digit_count(b"1\0x2"), 2);
assert_eq!(ascii_digit_count("123".as_bytes()), 1);
}
}
Run these commands from ffi-lab:
cc -std=c11 -Wall -Wextra -Werror -c digits.c -o digits.o
ar rcs libdigits.a digits.o
rustc --edition 2024 main.rs -L native=. -l static=digits -o ffi-demo
./ffi-demo
rustc --edition 2024 --test main.rs -L native=. -l static=digits -o ffi-tests
./ffi-tests
The program prints ASCII digits: 3; the test executable reports one passing test. -L native=. tells Rust where to find the library, and -l static=digits links libdigits.a. There is no unmentioned Cargo build script or external crate.
The unsafe extern "C" block is required syntax for foreign declarations in the 2024 edition. The declaration's author must check the C signature, including parameter types, return type and target ABI. The compiler cannot compare this declaration with digits.c for you. See unsafe extern blocks.
Review the boundary's proof
For this wrapper, the proof is concrete:
- C's
unsigned charcorresponds toc_uchar;size_tcorresponds tousizeon the native target used for this build. - The slice provides initialized bytes that remain alive throughout the call.
- C reads only indices strictly below the supplied length, including no reads for an empty buffer.
- The function does not write through the shared pointer or retain it for later.
- The returned count is at most the input length, so its increment cannot overflow
size_t.
A test supports the expected digit-count behavior. It does not replace checking every C access. A future edit that stores the pointer globally, reads one extra byte, or writes through it would invalidate the safe Rust wrapper even if its signature stayed unchanged.
Keep the raw declaration private and export only the checked wrapper when making a library. Review the foreign implementation and its documented ownership rules whenever it changes. For larger native integrations, build and binding tooling can automate mechanics, but cannot invent the safety proof. The Rustonomicon's FFI chapter covers those responsibilities in more depth.
Layout and strings are separate contracts
Rust's default struct layout is not a C ABI promise. #[repr(C)] gives a struct C-compatible field layout rules for that target, but does not make every field FFI-safe. An owned String, Vec<T>, Rust reference with an undocumented lifetime, or trait object is not a general C-facing field type. Prefer explicit primitive fields, pointers and lengths, plus documented ownership.
Matching layout also does not define a portable file format: padding, alignment and endianness still matter. Do not serialize a struct by reading its raw memory, especially uninitialized padding. See the Reference on C representation.
Our example uses bytes plus a length, so embedded zero bytes are ordinary data. A C string instead ends at a zero byte:
CStringowns a nul-terminated buffer and rejects interior nul bytes when constructed withCString::new.CStris a borrowed view of a C string. Constructing it from a raw pointer requires proof of a valid terminator within readable live memory.- Neither type guarantees UTF-8. Use
to_strto validate or choose an explicitly lossy conversion. as_ptr()borrows storage. Keep the owningCStringalive while C uses it.- Do not free a Rust allocation with C's
free, or turn a pointer owned by C into a Rust owner unless the documented allocator and ownership contract permits it.
See CString and CStr. A later callback from C needs additional lifetime and synchronization design. A panic must not cross a non-unwinding C ABI boundary; contain failures before returning, and follow the ABI's unwinding rules.
Idioms and pitfalls
- Prefer a safe standard-library operation when one exists.
split_at_mutis a checked abstraction; two raw mutable slices require a disjointness proof. - A non-null pointer is not necessarily valid. A length is not proof that the allocation actually contains that many elements.
- Do not create a reference first and validate afterwards. Creating an invalid reference can already violate Rust's rules.
- Unsafe code may rely on private safe code preserving an invariant. Keep the entire abstraction small enough to audit.
unsafe impl SendandSyncare serious thread-safety claims, not compiler-error workarounds.- Tests, sanitizers and tools such as Miri can find mistakes within their supported scope. A passing run is not a proof of soundness, and foreign code may need separate native tooling.
Exercises
1. Review an inadequate wrapper
A public safe function accepts *const u8 and a length. It checks for null and then calls from_raw_parts. Why is that insufficient? Propose an interface for ordinary Rust callers and a contract for unavoidable raw callers.
2. Split safely
Write a complete safe program that changes the first and second halves of [1, 2, 3, 4] through two mutable slices. Explain what precondition the standard-library function checks.
3. Check the FFI semantics
What should the C example return for an empty buffer, b"1\0x2", and the UTF-8 bytes of "123"? Would replacing the length-aware loop with a C-string scan preserve the contract?
Solutions
1. Put proof in types or an unsafe contract
Use &[u8] for ordinary Rust callers. A safe function taking arbitrary raw pointers cannot infer allocation validity, initialization, extent or aliasing from null checks. If raw inputs are unavoidable, expose an unsafe fn with a precise # Safety section, or validate them using an external source of authority that actually establishes those facts. Do not claim that arbitrary addresses can be validated by probing them.
2. Ask the library for disjoint borrows
This complete block can replace boundary-lab/src/main.rs:
fn main() {
let mut values = [1, 2, 3, 4];
let (left, right) = values.split_at_mut(2);
left[0] = 10;
right[0] = 30;
assert_eq!(values, [10, 2, 30, 4]);
}
The split index must be at most the slice length; split_at_mut panics otherwise. Once checked, the method can return disjoint mutable slices safely. A split at zero or at the length is also valid.
3. Count bytes under the declared rules
The results are 0, 2 and 1. Only ASCII digit bytes count; full-width Unicode digits do not. A C-string scan would stop at the embedded nul and would require a terminator that a general Rust byte slice does not supply. It would change the contract and could read outside the allocation.
Key takeaways
- An unsafe operation needs an explicit argument for validity; an unsafe function must document what its caller must prove.
- Safe wrappers must be sound for every input their safe types permit.
- FFI requires agreement on ABI, representation, lifetime, mutation, ownership and unwinding.
- Keep raw operations private, minimize their scope, and pair behavioral tests with a reviewable safety argument.
Next lesson
Finish with the Practical Rust CLI capstone, using safe Rust to build and test a complete command-line tool.

