Skip to content
advanced Phase 14 · Unsafe Rust and FFI

The unsafe Keyword

Learn the four capabilities unlocked inside unsafe blocks and their costs.

50m
0 problems
Topic Progress 0%

Four Powers, One Keyword: What Actually Becomes Legal Inside unsafe

A ring buffer that hands elements out of a pre-allocated array, a struct shared across two threads, a block of memory a C library just returned — each needs an operation the compiler refuses to accept. The refusal is intentional: the compiler cannot prove that the operation keeps memory valid, so it makes you write unsafe and take the proof obligation onto yourself. unsafe does not mean "this code is dangerous"; it means "this code leans on invariants the borrow checker cannot see."

The question interviewers ask in this phase more than any other is precise: what does unsafe actually unlock? The answer is exactly four powers, and the compiler guarantees that nothing else about your code changes.

Power unlocked Written as The invariant you now own
Dereference a raw pointer unsafe { *p } non-null, aligned, initialized, live, correctly aliased
Call a foreign function unsafe { abs(-7) } the C contract: argument ranges, ownership, error codes
Define or implement an unsafe trait unsafe impl Send for H {} the global property the trait promises
Read or write a static mut unsafe { HITS += 1 } exclusive access to a program-wide location

Every one of them in a single file:

use std::os::raw::c_int;

unsafe extern "C" {
    fn abs(v: c_int) -> c_int;          // declaring the symbol is safe; calling it is not
}

static mut HITS: u64 = 0;               // program-wide mutable state

unsafe trait Register {
    /// Promised by the implementor: a value valid for the target register.
    fn raw(&self) -> u64;
}

struct Counter;
unsafe impl Register for Counter {      // power 4, at item level — no block needed
    fn raw(&self) -> u64 { 7 }
}

fn main() {
    let mut nums = [10, 20, 30];
    let p: *mut i32 = nums.as_mut_ptr();   // forming a raw pointer is SAFE by itself

    let magnitude = unsafe {
        *p = 99;                           // power 1: dereference a raw pointer
        abs(-7)                            // power 2: call a foreign function
    };
    let hits = unsafe { HITS += 1; HITS }; // power 3: touch a static mut

    println!("{nums:?} magnitude={magnitude} hits={hits}");
}

Notice what never needed an unsafe block: nums.as_mut_ptr(), vec.as_ptr(), NonNull::new, casting *const T to *mut T. Creating a raw pointer is always safe — the compiler only demands unsafe when you dereference one. The asymmetry is the design: pointers can be formed and stored freely; the danger starts the moment you read through them.

unsafe also does not grant you the rest of the language for free. It does not disable the borrow checker, the move checker, slice bounds checks, arithmetic overflow checks, or type checking. A Vec<i32> indexed out of range inside an unsafe block still panics with index out of bounds: the len is 3 but the index is 7. You have to reach for deliberately unchecked operations — get_unchecked, set_len, write — and each of those is itself an unsafe fn with its own proof obligation. The next chapter proves the borrow checker part with code you can run in ten seconds.

You have probably already used unsafe without noticing, in the previous topic Pin and Unpin Explained: Pin::new_unchecked(&value) requires an unsafe block because you promise the compiler that the address of value will never change. The compiler cannot track that promise across your whole program, so it hands the responsibility to you in the form of a safety comment. That convention — unsafe block plus a written argument — is the working definition of the keyword, and it is what the rest of this phase builds on.

Two declarations round out the vocabulary. An unsafe fn is a function whose callers must uphold a contract, so calling it needs an unsafe block. And inside an unsafe fn body, the old editions treat every operation as implicitly unsafe; the unsafe_op_in_unsafe_fn lint — deny-by-default in edition 2024 — forces you to open a nested unsafe { } around each individual operation instead. Once you can name the four powers on sight, you are ready for the pointer mechanics in Raw Pointers.

unsafe Is a Scope, Not a Kill Switch: What the Borrow Checker Still Enforces

The claim you will hear from engineers who have never written much unsafe code is that unsafe "turns off the borrow checker." It takes ten seconds and three snippets to disprove, and being able to do that on command is a strong interview answer.

Here is a function where the two mutable borrows are wrapped in an unsafe block:

fn main() {
    let mut x = 5;
    unsafe {
        let a = &mut x;
        let b = &mut x;
        println!("{a} {b}");
    }
}
error[E0499]: cannot borrow `x` more than once at a time
  --> src/main.rs:5:17
   |
4  |         let a = &mut x;
   |                ------ first mutable borrow
5  |         let b = &mut x;
   |                ^^^^^^ second mutable borrow

The block changed nothing. unsafe relaxes four specific rules and only those four — references written in safe code are still governed by ownership and non-aliasing, moves are still checked, and types are still checked. What unsafe adds is the ability to reach around those rules with a raw pointer, at which point proving non-aliasing becomes your job, not the compiler's.

Bounds checks survive too:

let v = vec![1, 2, 3];
unsafe {
    println!("{}", v[7]);   // still panics: index out of bounds: the len is 3 but the index is 7
}

Indexing goes through Index, which performs the check regardless of context. Skipping it requires an explicitly unsafe operation such as v.get_unchecked(7) — and the name is the contract: you are asserting 7 < v.len(), and if you are wrong the program is undefined rather than merely panicking. This is the trade in one line: safe code fails loudly and locally; unsafe code that lies fails silently and somewhere else, possibly seconds later in another thread.

The scope of the keyword is purely lexical. Statements after the closing brace are safe again, and the compiler enforces that the block contains only operations the four powers cover — you cannot, for example, call an unsafe fn from a static initializer without one. The same lexical idea governs unsafe functions: a caller of unsafe fn must wrap the call in unsafe { }, and inside the body the unsafe_op_in_unsafe_fn lint (a warning by default, deny-by-default in edition 2024) makes each unsafe operation justify itself individually:

#[deny(unsafe_op_in_unsafe_fn)]
unsafe fn first_byte(p: *const u8) -> u8 {
    unsafe { *p }          // required: dereferencing a raw pointer
}

Crate authors get the same lever at whole-crate scale:

#![forbid(unsafe_code)]    // this crate contains no unsafe — and cannot be relaxed

forbid is stronger than deny: a nested #[allow(unsafe_code)] can lift a denial for one module, but it can never lift a forbid. That matters when you own a service crate and want CI to prove that unsafe lives only in a designated subsystem. The practical pattern is #![deny(unsafe_code)] at the crate root plus an explicit #[allow(unsafe_code)] on the single module that touches pointers, so every future unsafe block has to fight for its place on the page.

Interview framing: when an interviewer asks whether unsafe disables the borrow checker, answer with the distinction, not a yes or no — the borrow checker still governs references; unsafe only adds four new operations that were previously forbidden. Then give the E0499 example. It shows you have run the experiment instead of memorizing the claim.

Wrapping the Unsafe Core So Callers Never Write an unsafe Block

A crate's job is to publish safe APIs. If your users have to open an unsafe block to call you, you have pushed your proof obligations onto everyone downstream — and one careless caller turns your crate into undefined behavior they cannot debug. The discipline that makes unsafe reviewable is a shape, not a skill: a tiny unsafe core behind a safe facade that validates every precondition first.

Suppose you wrap a buffer that arrives from FFI as a pointer plus a length:

pub struct Buffer {
    ptr: *mut u8,
    len: usize,
}

impl Buffer {
    /// # Safety
    /// `ptr` must be non-null, aligned for `u8`, and reference `len` initialized
    /// bytes that remain valid and uniquely owned for `'static`.
    pub unsafe fn from_raw_parts(ptr: *mut u8, len: usize) -> Self {
        Buffer { ptr, len }
    }

    pub fn len(&self) -> usize {
        self.len
    }

    pub fn as_slice(&self) -> &[u8] {
        // SAFETY: the constructor contract guarantees ptr is non-null and len
        // bytes are initialized; &self gives shared access, so no &mut alias exists.
        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
    }

    pub fn is_empty(&self) -> bool {
        self.len == 0
    }
}

Where do the preconditions get checked? Conditions your own crate can verify — a null pointer, a ridiculous length, a misaligned address — belong in a safe try_* constructor that rejects them before any unsafe code runs:

pub const MAX_LEN: usize = 1 << 20;

pub fn try_from_raw(ptr: *mut u8, len: usize) -> Result<Buffer, BufferError> {
    if ptr.is_null() {
        return Err(BufferError::Null);
    }
    if len > MAX_LEN {
        return Err(BufferError::TooLarge(len));
    }
    Ok(unsafe { Buffer::from_raw_parts(ptr, len) })
}

Conditions only the outside world can guarantee — that the bytes really are initialized, that no second owner exists, that the allocation outlives the Buffer — cannot be checked from inside Rust. Those stay on the trusting path and are written into the # Safety contract, so the one caller who can uphold them knows precisely what is being asked. Blurring the two categories is how otherwise careful crates go unsound: a length check presented as if it also proved initialization.

Three rules are doing the work. First, the public surface is entirely safe: as_slice() cannot be called wrongly because the type's own constructor already enforced what the unsafe core needs. Second, every unsafe block carries a SAFETY: comment naming the invariant and where it is established — not "this is fine", but the actual argument a reviewer can check line by line. Third, unsafe functions document their caller contract under a # Safety heading, because unlike safe code, an unsafe fn gives the caller the ability to break your crate.

Keep the unsafe footprint small enough to audit in one sitting. The usual layout is one module — src/buffer.rs, src/ffi.rs — holding every unsafe block in the crate, with #![deny(unsafe_code)] at the root and #[allow(unsafe_code)] on that module alone:

#![deny(unsafe_code)]                 // crate root: no unsafe by default

#[allow(unsafe_code)]                 // the one module that earns it
mod sys {
    // ... raw pointers, extern blocks, unchecked operations
}

The payoff is mechanical: a reviewer opens mod sys, reads a few dozen lines with safety comments, and can certify the crate. Without that shape, unsafe is sprinkled across dozens of functions and every code review becomes a memory-model exam. Clippy's clippy::undocumented_unsafes lint enforces the comments so the convention survives contact with a deadline.

Interviewers probe this directly: "How would you expose a C library so your callers never write unsafe?" Answer with the validation-first pattern, the # Safety docs, the deny-by-default crate, and one concrete example — then mention that the pointer-level obligations you just documented are exactly what Raw Pointers teaches you to discharge one by one.

Quiz

1. Which one of these compiles WITHOUT any unsafe block?

Question 1 options

2. You wrap two mutable borrows of the same local variable inside an unsafe block. What happens?

Question 2 options

3. A teammate argues that static mut is fine for a counter incremented from two threads because every access is inside an unsafe block. What is the correct rebuttal?

Question 3 options

4. A library crate puts #![forbid(unsafe_code)] at its root. Which statement is correct?

Question 4 options

Flashcards

Question

What four capabilities does an unsafe block unlock?

Answer

Dereference a raw pointer, call a foreign function, define or implement an unsafe trait, and read or write a static mut. Nothing else changes — borrow checking, move checking, bounds checks, overflow checks, and type checking all still run.

Question

Does unsafe disable the borrow checker?

Answer

No. The borrow checker keeps governing references inside the block — two &mut borrows of the same local still produce E0499. unsafe only adds four previously forbidden operations; once you route around the rules with a raw pointer, upholding non-aliasing becomes your job instead of the compiler's.

Question

Why does forming a raw pointer need no unsafe block, but dereferencing one does?

Answer

Creating a pointer (v.as_mut_ptr(), ptr as *const T) is always valid — it carries no promise about the memory. Dereferencing reads or writes memory whose validity the compiler cannot prove (non-null, aligned, initialized, live, correctly aliased), so the proof obligation is transferred to you via unsafe.

Question

What does an unsafe fn declare, and how does unsafe_op_in_unsafe_fn change its body?

Answer

It declares a caller contract: every call site must be wrapped in unsafe and must uphold the documented preconditions. Historically the body was an implicit unsafe block; with unsafe_op_in_unsafe_fn (deny-by-default in edition 2024) each unsafe operation inside the body needs its own explicit unsafe { } block.

Question

What is the recommended crate shape when a crate must use unsafe?

Answer

A tiny unsafe core in one module, wrapped by a safe facade that validates every precondition before it runs; #![deny(unsafe_code)] at the crate root with #[allow(unsafe_code)] on that single module; // SAFETY: comments above every unsafe block and a # Safety section on every unsafe fn. Callers never write unsafe themselves.

Revision Notes

Key Takeaways

  • 1. unsafe unlocks exactly four powers — raw pointer dereference, foreign calls, unsafe trait implementations, static mut access — and everything else the compiler checks keeps running
  • 2. Forming a raw pointer is safe; dereferencing one transfers the validity proof (non-null, aligned, initialized, live, aliased) from the compiler to you
  • 3. unsafe is lexically scoped: bounds checks, move checks, and borrow checks inside the block behave exactly as they do outside it
  • 4. The working unit of unsafe is not the keyword but the written safety argument: a SAFETY comment above the block and a # Safety contract on every unsafe fn
  • 5. Wrap unsafe in a safe facade with deny(unsafe_code) outside one audited module, so downstream callers cannot trigger UB and reviewers have a small surface to certify

Interview Tips

  • • "Does unsafe turn off the borrow checker?" — no; answer with the distinction (references still checked, four rules relaxed) and the E0499 counter-example, then add that raw pointers route around the rules and shift the proof to you
  • • "List what unsafe lets you do" — the four powers, verbatim and in order: dereference raw pointers, call foreign functions, implement unsafe traits, access static mut; then say what it does NOT disable (bounds, moves, types)
  • • "Why does my Vec index still panic inside unsafe?" — indexing goes through Index, which bounds-checks regardless of context; skipping it needs get_unchecked, an unsafe fn with its own precondition you must prove
  • • "How do you make unsafe code reviewable?" — safe facade that validates first, tiny unsafe core in one module, SAFETY comments naming each invariant, # Safety docs on unsafe fns, deny(unsafe_code) everywhere else, and clippy::undocumented_unsafes to enforce the comments

Cheat Sheet

The unsafe Keyword — Cheat Sheet

The four powers (and only these four):

Power Syntax Invariant you take on
Dereference a raw pointer unsafe { *p } non-null, aligned, initialized, live, correctly aliased
Call a foreign function unsafe { abs(-7) } C contract: argument ranges, ownership, who frees what
Define/implement an unsafe trait unsafe impl Send for T {} the global property the trait promises
Touch a static mut unsafe { HITS += 1 } exclusive access to program-wide state

Still enforced inside an unsafe block: borrow checker (E0499 still fires), move checker (E0382), slice bounds checks (v[7] still panics), overflow checks, type checking.

Asymmetry to remember: forming a raw pointer is safe (p = v.as_mut_ptr()); dereferencing it is not.

Crate-level control:

#![forbid(unsafe_code)]   // absolute — an inner #[allow] cannot lift it
#![deny(unsafe_code)]     // default-off, liftable by #[allow] in one module

#[allow(unsafe_code)]
mod sys { /* the only module allowed to touch pointers */ }

Unsafe functions: unsafe fn = callers must uphold a contract. Edition 2024 makes unsafe_op_in_unsafe_fn deny-by-default, so each unsafe op needs its own nested unsafe { }.

Required comment style: // SAFETY: <invariant>, established where> above every unsafe block; # Safety section on every unsafe fn; enforce with clippy::undocumented_unsafes.

The shape: safe facade → validates preconditions → tiny unsafe core. Callers never write unsafe.

Continue Learning

Before You Start

Next Topics

Related Reading