The compiler stops you at the toll booth
You write a function that takes a raw pointer from a C library, dereference it, and suddenly the compiler halts with E0133. It tells you that dereferencing a raw pointer requires an unsafe block. You already verified the pointer is valid. You just want the code to run. Rust says no.
Rust divides operations into two camps. Safe operations get automatic guarantees from the compiler. Unsafe operations skip those guarantees. The unsafe keyword is the toll booth. It does not change how the CPU executes your code. It changes how the compiler treats your code. Think of it like a construction site. The safe zone has guardrails, automatic warnings, and a supervisor checking every step. The unsafe zone removes the guardrails. You still need to know how to use a hammer, but if you swing it wrong, nothing stops you from hitting your thumb. The unsafe block is just you signing a waiver that says you understand the risks.
The compiler is not trying to stop you. It is asking for a signature.
The minimal fix
You fix E0133 by wrapping the exact operation that breaks the rules in an unsafe block. The block tells the compiler you accept responsibility for verifying safety manually.
fn main() {
let x = 42;
let ptr = &x as *const i32;
// Dereferencing a raw pointer skips null checks and alignment checks.
// The compiler refuses to generate machine code without explicit consent.
// let value = *ptr; // E0133: use of unsafe requires unsafe block
// Wrap the operation to acknowledge manual verification.
let value = unsafe {
// SAFETY: ptr was created from a valid reference to x,
// so it is non-null, properly aligned, and points to live data.
*ptr
};
println!("{value}");
}
Wrap the exact line that breaks the rules. Nothing more.
What the compiler actually checks
When you add the unsafe block, the compiler stops verifying memory safety invariants for that specific block. It still checks syntax, type mismatches, borrow rules outside the block, and panic unwinding. The unsafe keyword is a compile-time directive, not a runtime flag. Your program does not run faster or slower because of it.
Rust triggers E0133 for five specific categories of operations. Knowing them prevents surprise errors later.
Dereferencing raw pointers requires unsafe because raw pointers can be null, dangling, or misaligned. Calling unsafe functions requires unsafe because the function author explicitly marked it as requiring manual verification. Accessing mutable static variables requires unsafe because the compiler cannot track concurrent access at compile time. Implementing unsafe traits requires unsafe because the trait contract demands manual proof of invariants. Accessing union fields requires unsafe because the compiler cannot know which variant currently holds valid data.
Every one of these operations shares the same pattern. The compiler cannot prove safety automatically. You must provide the proof.
Trust the boundary. The compiler still watches everything outside it.
Building a safe wrapper
Real Rust code rarely leaves unsafe blocks exposed in public APIs. The idiomatic pattern is to hide the unsafe operation behind a safe function that enforces the invariants. This keeps the rest of your codebase protected while giving you the performance or interoperability you need.
/// Reads a value from a raw pointer, assuming the caller guarantees validity.
/// Panics if the pointer is null or misaligned.
fn read_from_ptr(ptr: *const i32) -> i32 {
// Validate the pointer before entering the unsafe zone.
// This shifts the burden from the caller to this function.
if ptr.is_null() {
panic!("Attempted to dereference a null pointer");
}
// The unsafe block is now tiny and isolated.
unsafe {
// SAFETY: 1. ptr is non-null (checked above).
// 2. ptr is properly aligned for i32 (guaranteed by caller).
// 3. ptr points to a valid, initialized i32.
*ptr
}
}
fn main() {
let data = 100;
let ptr = &data as *const i32;
let result = read_from_ptr(ptr);
println!("Read: {result}");
}
The public function signature contains no unsafe. Callers interact with it like any other safe Rust function. The unsafe block sits behind a wall of runtime checks and documentation. This pattern is the foundation of crates like std::vec::Vec and std::fs::File.
Build the safe API first. Hide the raw pointer behind it.
Where things go wrong
Developers new to unsafe usually make the same three mistakes. The first mistake is making the block too large. Wrapping an entire function in unsafe defeats the purpose. The compiler can no longer help you catch safe bugs inside that function. Keep the block tight around the exact line that requires manual verification. The community calls this the minimum unsafe surface rule.
The second mistake is skipping the // SAFETY: comment. In professional Rust code, that comment is mandatory. It is not a suggestion. It is a proof that the invariants hold. If you cannot write a clear, numbered list of why the block is safe, you do not have a proof. Treat the comment as a legal contract. If you cannot write it, you do not have one.
The third mistake is assuming unsafe disables all compiler checks. It does not. Array bounds checks still panic. Type mismatches still fail to compile. Borrow checker rules still apply to safe references inside the block. unsafe only relaxes memory safety guarantees for the specific operations listed above.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about.
When to reach for unsafe
Rust gives you many tools. Picking the right one depends on what you are trying to solve.
Use unsafe for FFI when you call C or another language and have to cross out of Rust's safety. Use unsafe when you are implementing a safe abstraction yourself, like a custom allocator or a data structure that needs interior mutability. Use unsafe when measured profiling proves that a safe boundary check is the bottleneck and you can manually verify the bounds. Reach for safe references when lifetimes are straightforward. Reach for std::cell::Cell or std::rc::Rc when you need interior mutability without raw pointers. Reach for Option and Result when you are handling missing data instead of null pointers. Reach for std::sync::Mutex when you need thread-safe shared state.
Keep the unsafe surface smaller than your coffee mug.