Learning Rust · Part III · Chapter 18

Unsafe, and the contract

You have spent the whole language so far being told no by the borrow checker. unsafe is the keyword that lets you say I have checked this myself. It does not turn the rules off; it moves the burden of proving them from the compiler onto you, in exchange for five specific extra powers.

01

What unsafe is, and what it is not

You have reached the last language feature in this book, and it has a reputation that arrives before it does. People say unsafe is where you turn Rust off: where the borrow checker stops watching, where the guarantees lapse, where C creeps back in. Almost none of that is true, and the part that is true is narrower than the rumor. So before any power, the correction, stated plainly: unsafe does not switch off the rules and does not disable the borrow checker. Ownership, borrowing, lifetimes, the move semantics, the type system: all of it is still on inside an unsafe block, checking every line exactly as it does outside one.

What unsafe actually does is small and specific. It unlocks five extra operations that are otherwise forbidden, and it changes who is responsible for proving they are correct. Outside an unsafe block, the compiler proves your code cannot corrupt memory, and refuses to build anything it cannot prove. Inside one, for those five operations only, the compiler steps back and trusts you to have done the proof yourself. The burden of proof shifts from the machine to the programmer. Nothing else moves.

That is the whole bargain, and it is worth holding onto, because the rest of the chapter is just detail hung on this one frame. unsafe is not permission to be reckless. It is a clearly marked region where you take on an obligation the compiler usually carries for you, in return for a handful of powers you cannot get any other way.

The one-sentence version

unsafe does not remove a single rule of safe Rust. It adds five powers the compiler cannot check, and in exchange makes you the one who promises those five uses are correct. Everything else stays exactly as checked as it was.

One honest note up front about where this is going. This chapter teaches the unsafe contract in full, but the book barely uses unsafe until much later. You write it for real exactly once, in Part VII, when the storage engine maps a file straight into memory and decodes records without copying them. This chapter is the contract; that chapter is the payoff. For now the goal is to read unsafe, understand what it claims, and respect what it costs.

02

The five powers, named

If you come from Go or Python, you may expect unsafe to be a vague mode, a flag that loosens things in general. It is not. The keyword unlocks an exact, closed list of five operations, and nothing outside that list changes. Memorize the list and you have memorized everything unsafe can do:

  1. Dereference a raw pointer. Read or write through a *const T or *mut T, a pointer the compiler does not vouch for.
  2. Call an unsafe function or method. Invoke a function marked unsafe fn, which means it has a precondition only you can guarantee.
  3. Access or modify a mutable static. Touch a static mut, a piece of global mutable state shared by the whole program.
  4. Implement an unsafe trait. Write unsafe impl for a trait whose correctness the compiler cannot verify, like Send or Sync.
  5. Access the fields of a union. Read a field of a union, where several fields share the same bytes and only you know which one is currently meaningful.

That is the entire grant. You still cannot index out of bounds and have the compiler shrug; data[99] on a four-element array still panics, inside unsafe or out. You still cannot create two &mut to the same value through safe references; the borrow checker still says no. unsafe hands you five keys, each to a specific door, and leaves every other lock in the building exactly as it was.

Why the word is unsafe, not dangerous

The keyword reads like a warning, and in a sense it is, but the precise meaning is narrower. unsafe marks code whose soundness the compiler cannot verify, so a human must. It is not a claim that the code is wrong or risky; correctly written unsafe is perfectly safe to run. The word names who did the checking, you rather than the compiler, not how dangerous the result is.

03

Raw pointers: addresses with no promises

The first power is the foundation for most of the others, so start there. A raw pointer is a value that holds a memory address and nothing else: no guarantee the address is valid, no guarantee it points at a live value, no borrow tracking. Rust writes them as *const T for a read-only pointer and *mut T for a writable one. They are the bare machine pointer you may know from C, and they are deliberately stripped of every protection a reference carries.

Here is the split that surprises people, and it is the whole point of this section. Creating a raw pointer is safe. Holding an address breaks no rule, so you can build one in ordinary code with no unsafe in sight. Dereferencing it is unsafe, because reading or writing through an address the compiler never checked is where the real risk lives. Run this and watch both halves: the pointers are made in plain safe code, and only the read sits inside unsafe:

two_pointers.rs
// You can MAKE a raw pointer in safe code. You can only READ through one
// inside an unsafe block, where you promise the pointer is still valid.
fn main() {
    let value: i32 = 42;

    // Two raw pointers into the same value. Creating them is safe: no rule
    // is broken just by holding an address.
    let p1: *const i32 = &value;
    let p2: *const i32 = &value;

    // Dereferencing is the first of the five powers, so it needs unsafe.
    // The block is tiny on purpose: only the thing that needs the power.
    let (a, b) = unsafe { (*p1, *p2) };

    println!("value = {value}");
    println!("through p1 = {a}, through p2 = {b}");
    println!("p1 == p2 ? {}", std::ptr::eq(p1, p2));
}

Two raw pointers, both pointing at the same i32, both constructed without ceremony. The line that needs the keyword is the one that reads through them, (*p1, *p2), and notice how small the unsafe block is: it wraps the dereference and nothing more. That is not an accident of this example, it is the discipline you will see throughout. The unsafe block holds only the operation that genuinely needs the power, so a reader can check it at a glance.

Make freely, dereference carefully

Building a raw pointer is just recording an address, so it stays in safe code. The danger is in following it, because the compiler never verified the address still points at a live, correctly typed value. That is why the dereference, and only the dereference, demands an unsafe block where you take responsibility for the address being good.

04

A raw pointer is not a reference

You already know one way to name another value without owning it: a reference, &T or &mut T, taught back in the ownership chapters. So the natural question is why Rust needs a second kind of pointer at all. The answer is that a reference comes with promises the compiler enforces, and sometimes you need a pointer with none of those promises. The two are not interchangeable; they sit at opposite ends of a trade.

A reference is always valid. The borrow checker guarantees a &T points at a live value of type T, that it never outlives that value, and that it never coexists with a conflicting &mut. A raw pointer guarantees none of this. It can be null. It can dangle, pointing at memory that was freed. It can sit alongside any number of other pointers to the same place, mutable or not, with no borrow rule to stop it. Run this to see a reference and a raw pointer to the same value side by side, and a null raw pointer that exists perfectly happily as long as nobody follows it:

raw_vs_ref.rs
// A reference is always valid and always points at a live value: the
// compiler guarantees it. A raw pointer makes no such promise. It can be
// null, can dangle, and is exempt from the borrow rules. That freedom is
// exactly why dereferencing one is unsafe: nobody checked it but you.
fn main() {
    let n: i64 = 7;

    let r: &i64 = &n;          // reference: the compiler vouches for this
    let p: *const i64 = &n;    // raw pointer: same address, zero promises
    let nothing: *const i64 = std::ptr::null();

    println!("reference reads {}", *r);          // safe: r is checked
    println!("raw pointer reads {}", unsafe { *p }); // unsafe: you vouch for p

    // A raw pointer can be null. Asking before dereferencing is YOUR job now;
    // the type system stepped back and handed you the responsibility.
    if nothing.is_null() {
        println!("the null pointer is null, so we do not read it");
    }
}

Reading through r, the reference, needs no unsafe: the compiler already proved it is safe. Reading through p, the raw pointer to the very same i64, needs unsafe, because as far as the compiler is concerned p is just a number that might point anywhere. And nothing, a null pointer, is a legal value you can hold and test all day; it only becomes a problem the instant you dereference it. Checking is_null() before following a pointer is now yourjob, not the type system’s.

Reference: checked. Raw pointer: trusted.

A &T is a pointer the compiler vouches for: live, correctly typed, borrow-tracked. A *const T is the same address with the guarantees removed, so the compiler stops vouching and starts trusting you. You reach for a raw pointer exactly when you need to do something the borrow checker cannot express, and you accept the bookkeeping that comes with it.

05

unsafe fn, and the contract you must document

The second power is calling an unsafe fn, and this is where the word contract in the chapter title earns its place. A function marked unsafe fn is one with a precondition the compiler cannot check: a promise the caller must keep for the call to be sound. The function cannot enforce the promise itself, so it does the next best thing. It writes the promise down, and the language forces every caller to acknowledge it by wrapping the call in unsafe.

The convention for writing the promise down is a # Safety section in the doc comment: a paragraph that states, in plain language, exactly what the caller must guarantee. This is not decoration. It is the actual interface of the function, as binding as the type signature, because it is the only place the precondition lives. Run this, where an unsafe fn reads a slice element with no bounds check, and read its # Safety note as carefully as you would read the return type:

unsafe_fn_contract.rs
// An unsafe fn is one whose CALLER must uphold a contract the compiler
// cannot check. The obligation is documented in a # Safety section, and the
// call site must say `unsafe`, acknowledging it read and met the contract.

/// Reads the element at `index` without a bounds check.
///
/// # Safety
/// The caller must ensure `index < slice.len()`. Passing an out-of-range
/// index is undefined behavior: it reads memory that is not yours to read.
unsafe fn nth_unchecked(slice: &[u32], index: usize) -> u32 {
    // get_unchecked is itself unsafe; we are allowed to call it here because
    // our own # Safety contract already promised the index is in range.
    *slice.get_unchecked(index)
}

fn main() {
    let data = [10u32, 20, 30, 40];

    // We can SEE that 2 is in range, so the contract holds. We say `unsafe`
    // to take responsibility for that fact on the record.
    let value = unsafe { nth_unchecked(&data, 2) };
    println!("data[2] = {value}");

    // The contract is a human promise, not a compiler guarantee. Calling
    // with index 99 here would be undefined behavior, so we never do.
    println!("contract upheld: 2 < {}", data.len());
}

The function nth_unchecked skips the bounds check that ordinary indexing performs, which is the whole reason it is fast and the whole reason it is unsafe. Its # Safety section states the contract in one line: the caller must ensure index < slice.len(). At the call site, the unsafeblock is the caller’s signature on that contract, a statement on the record that says I have read the precondition and I guarantee I am meeting it. Here the caller can see that 2 is in range, so the promise holds and the call is sound.

Read this as a transfer of obligation, not a removal of one. Inside an ordinary safe function, the compiler owes you the proof that nothing goes wrong. When you call an unsafe fn, that debt moves to you for the duration of the call. The # Safety section is the itemized bill: it tells you exactly what you owe.

The unsafe block is not a magic word, it is a promise

Writing unsafe around a call does not make the call correct, and it does not make the compiler check anything extra. It does the opposite: it tells the compiler to stop checking and trust your claim that the contract holds. If the contract does not hold, your unsafe block is a lie, and the result is undefined behavior, the worst failure mode in the language. The keyword is a promise you sign, not a shield you hide behind.

06

The real shape: a safe abstraction over an unsafe core

So far every unsafe use has leaned on the caller to keep a promise, which sounds fragile if you imagine it spread across a whole codebase. The thing that makes unsafe survivable in practice is a single design pattern, and it is the most important idea in this chapter: wrap a tiny, audited unsafe core inside a safe abstraction whose public signature cannot be misused. Callers get an ordinary safe function; the dangerous part is sealed in one small place where the proof is written once and checked once.

The standard library is built this way from top to bottom. Methods you have used without a second thought, like splitting a mutable slice into two halves, are safe functions wrapping a few lines of unsafe. The borrow checker cannot see that the first half and the second half of a slice never overlap, so the implementation drops to raw pointers to express it, proves the halves are disjoint, and hands back two ordinary &mut slices. Here is that exact shape, small enough to read in full. Run it:

split_in_two.rs
// The discipline that makes unsafe survivable: wrap a tiny unsafe core in a
// SAFE function whose signature cannot be misused. Callers never write
// `unsafe`; the proof of correctness lives in one small, audited place.

// Split one mutable slice into two non-overlapping halves. The borrow checker
// alone cannot see that `..mid` and `mid..` never touch the same element, so
// the standard library uses a touch of unsafe to express it. Here is the shape.
fn split_in_two(data: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = data.len();
    let ptr = data.as_mut_ptr();
    assert!(mid <= len, "mid {mid} is past the end {len}");

    // SAFETY: `mid <= len`, so both ranges stay inside the original slice, and
    // they do not overlap, so the two &mut slices alias nothing. That is the
    // whole proof, and it sits right next to the unsafe block it justifies.
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut scores = [3, 1, 4, 1, 5, 9];
    let (left, right) = split_in_two(&mut scores, 3);

    // Two live &mut into one array at once: safe code, because the unsafe core
    // already proved the halves cannot overlap.
    left[0] += 100;
    right[0] += 200;

    println!("after editing both halves: {scores:?}");
}

Look at the signature of split_in_two: it takes a &mut [i32] and returns two &mut [i32], with no unsafe anywhere in sight. A caller cannot misuse it, because the only inputs it accepts are ones for which the result is sound; the assert! rejects the rest before the unsafe core runs. Inside, the unsafe block is three lines, and right above it sits a SAFETY: comment spelling out why those three lines are correct: both ranges stay inside the slice, and they do not overlap, so the two mutable slices alias nothing. The proof lives next to the code it justifies.

That is the discipline in one picture. Keep the unsafe block tiny, keep its precondition written down right beside it, and put a safe signature in front of it so the rest of the program never has to know. A reviewer auditing this code checks one small block against one short comment, and once that block is correct, every safe caller is correct for free. That is how a language with an unsafe escape hatch still ships memory-safe programs by the million.

Small core, safe shell

The goal is never to write a lot of unsafe. It is to write as little as possible, concentrate it in one audited spot with its safety argument written beside it, and expose a safe function whose type signature makes misuse impossible. Unsafe you can review in an afternoon is unsafe you can trust; unsafe sprayed across a file is a liability.

07

The other three powers: statics, unions, unsafe traits

Two powers down, three to go, and each is a short story. Take the mutable static first. A static mutis a single global variable that the whole program shares and can mutate. It is unsafe to touch because globals and threads are a famous bad pairing: if two threads write to the same global at once, you have a data race, the precise bug Rust’s ownership rules exist to make impossible. The compiler cannot prove your accesses are serialized, so it makes each one unsafe. This program is single-threaded, so the accesses are sound, and they hide inside a safe bump function. Run it:

mutable_static.rs
// Power three: read or write a mutable static. A `static mut` is global
// shared state, so any access is unsafe: in a threaded program two threads
// could touch it at once, the exact data race the borrow checker exists to
// prevent. This program is single-threaded, so the accesses are sound.
static mut CALL_COUNT: u64 = 0;

// Wrap each access in a safe function with the unsafe block hidden inside.
// SAFETY: this whole program runs on one thread, so no two accesses to
// CALL_COUNT ever overlap.
fn bump() -> u64 {
    unsafe {
        CALL_COUNT += 1;
        CALL_COUNT
    }
}

fn main() {
    println!("call {}", bump());
    println!("call {}", bump());
    println!("call {}", bump());

    // Rust's own advice: a plain `static mut` is so easy to misuse that the
    // standard library gives you atomics and locks instead. Reach for those
    // first; a bare mutable static is a last resort, and rarely the right one.
}

It counts cleanly because nothing runs concurrently. The comment at the bottom carries the real lesson: a bare static mutis so easy to misuse that Rust’s own guidance is to reach for an atomic or a lock instead, tools from the standard library that make shared mutable state safe by construction. A mutable static is a last resort, and you will rarely meet a good reason for one.

The fifth power, reading a union field, is the same idea from a different angle. A union stores all of its fields in the same bytes, so only one field is meaningful at a time, and the compiler has no way to know which. Reading a field is therefore unsafe: you, not the type system, must know what was last written. This program writes a float and reads its raw bit pattern back as an integer, a well-defined reinterpretation of the same four bytes. Run it:

peek_the_bytes.rs
// Power five: read a field of a union. A union stores all its fields in the
// SAME bytes, so only one field is meaningful at a time, and the compiler
// cannot know which. Reading a field is unsafe because you, not the type
// system, must know what was last written there.
union Bits {
    as_float: f32,
    as_int: u32,
}

fn main() {
    let value = Bits { as_float: 1.5 };

    // SAFETY: we just wrote `as_float`, and reading the same 4 bytes back as a
    // u32 is a well-defined reinterpretation of that bit pattern.
    let raw = unsafe { value.as_int };

    println!("the f32 1.5 has the bit pattern 0x{raw:08X}");

    // Reading `as_float` back out again is equally fine, because those bytes
    // really do hold a valid f32 right now.
    println!("read back as f32: {}", unsafe { value.as_float });
}

That leaves the fourth power, the unsafe trait, and you have met its most famous members already without knowing their full story: Send and Sync, the marker traits that say a type is safe to move to another thread or share between threads. A trait is unsafe when the compiler relies on the implementor upholding a guarantee it cannot verify, so writing unsafe impl is you signing that guarantee. A safe function may then lean on the promise. Here is a toy with the same shape: an unsafe trait whose contract is this byte is never zero, and a safe function that divides by it without fear. Run it:

unsafe_trait.rs
// Power four: implement an unsafe trait. A trait is `unsafe` when the
// compiler relies on the implementor upholding a promise it cannot verify.
// The marker traits Send and Sync are the famous examples: implementing them
// by hand asserts "this type really is safe to move, or share, across
// threads", and the compiler trusts you. Here is a toy of that shape.

// An unsafe trait: the IMPLEMENTOR carries the obligation.
unsafe trait NonZeroByte {
    fn byte(&self) -> u8;
}

struct Always7;

// SAFETY: `byte` always returns 7, which is never zero, so the contract that
// the returned byte is non-zero holds for every value of this type.
unsafe impl NonZeroByte for Always7 {
    fn byte(&self) -> u8 {
        7
    }
}

// A safe function may now RELY on the unsafe trait's promise. It calls only
// safe methods, so callers of `reciprocal_ish` never write `unsafe`.
fn reciprocal_ish<T: NonZeroByte>(t: &T) -> u32 {
    // We trust the non-zero promise, so this never divides by zero.
    1000 / t.byte() as u32
}

fn main() {
    let value = Always7;
    println!("byte is {}", value.byte());
    println!("1000 / byte = {}", reciprocal_ish(&value));
}

The unsafe impl for Always7 carries a SAFETY: note explaining why the contract holds: the method always returns 7, which is never zero. Because that promise is signed, reciprocal_ish can be an ordinary safe function that divides by the byte and trusts it will never hit zero. Same pattern as everything else in the chapter: an obligation the compiler cannot check, made explicit, then relied on by safe code.

Send and Sync are usually given to you

You almost never write unsafe impl Send by hand. The compiler derives Send and Sync automatically for any type built entirely from parts that already have them, which is nearly everything. You only reach for the manual unsafe impl when you have built a type around raw pointers and have personally verified it really is safe to share. Until then, treat these two traits as something the compiler grants you, not something you assert.

08

Soundness is a discipline, not a keyword

Step back from the five powers and look at what they have in common, because that common thread is the real subject of the chapter. Every one of them is an operation whose correctness depends on a fact the compiler cannot see: that a pointer is valid, that an index is in range, that a thread is alone with a global, that a union holds the field you are reading, that a type really is thread-safe. unsafe is how you tell the compiler I know that fact and I am standing behind it.

The word for code that keeps every such promise is sound. Sound unsafe code never triggers undefined behavior, no matter how it is called, because every precondition it depends on is actually guaranteed. Unsound code is unsafe code that can be driven into undefined behavior by some input or some caller, even if it happens to work today. The entire goal of writing unsafe is soundness, and soundness is a property you argue for, not one the compiler hands you.

So the practice reduces to a few habits, the same ones every example in this chapter followed. Keep each unsafe block as small as it can be, holding only the operation that truly needs the power. Write a SAFETY:comment beside it that states why the precondition holds. Wrap the whole thing in a safe abstraction whose signature makes misuse impossible, so the unsafe surface the rest of your program sees is zero. Audit that small core as if your program’s correctness depends on it, because it does.

Sound, not just working

Working unsafe code does the right thing on the inputs you tried. Sound unsafe code does the right thing on every input that can reach it, because every promise it relies on is genuinely upheld. The gap between the two is where undefined behavior lives, and closing it is your job the moment you type the keyword.

One forward nod, then the recap. You now understand the contract; you have not yet had to sign it for anything real. That comes in Part VII, where the storage engine maps a file directly into memory and decodes records straight out of those bytes with no copy. There the raw pointers, the # Safety sections, and the small-core-behind-a-safe-shell pattern stop being demonstrations and start carrying real data. This chapter is the contract; that chapter is the work it pays for.

09

Read it again

Scroll back to the first program if you like, and read every unsafe block in this chapter once more with the frame in place. Each one is tiny. Each one wraps exactly the operation that needs the power and nothing else. Each one has, or could have, a one-line argument for why the promise it makes is true. And each one sits behind a signature that a caller can use without ever seeing the danger. That is not a coincidence of the examples; it is the whole craft of unsafe, compressed into a handful of small programs.

The whole chapter in six lines

  1. unsafe does not turn off the rules. The borrow checker and type system still run; it only adds five powers and shifts the proof burden to you.
  2. The five powers are: dereference a raw pointer, call an unsafe fn, touch a static mut, write unsafe impl, and read a union field. Nothing else changes.
  3. Raw pointers (*const T, *mut T) are addresses with no promises: making one is safe, dereferencing one is unsafe.
  4. An unsafe fn carries a contract its caller must uphold, documented in a # Safety section; the call-site unsafe block is your signature on it.
  5. The surviving pattern is a safe abstraction over a tiny, audited unsafe core, with a SAFETY: note beside it and a signature that makes misuse impossible.
  6. The goal is soundness: code that can never reach undefined behavior on any input, which you argue for, the compiler does not hand you. The book cashes this in for real in Part VII.
End-of-chapter assessmentCheck your understanding22 questions. Your first attempt is recorded on your report card.Take it →