Viscosity
Review
Part IX · Rust for TypeScript developers · Day 36

141. Unsafe Rust

clox reads its bytecode through a C pointer and checks nothing. drip-rs checks every read, and forbids itself the keyword that would let it stop. This chapter is about that keyword: what it unlocks, what it leaves switched on, and what giving it up cost.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Create raw pointers, and dereference them inside an unsafe block.
  • Say what unsafe allows, and which checks it leaves on.
  • Wrap an unchecked operation in a safe function with a SAFETY comment.
  • Explain what

One byte, two ways

Every instruction the Drip VM runs starts by reading a byte. clox, the C VM from Crafting Interpreters, does it with *ip++: read through a pointer, move it along, and trust that it is still inside the chunk. drip-rs does this (from vm.rs):

fn read_byte(&mut self) -> u8 {
    let byte = self.chunk.code[self.ip];
    self.ip += 1;
    byte
}

code[self.ip] compares ip with the length first, every time, and panics if it’s past the end. Could Rust read the C way? Yes, with one keyword. drip-rs chose not to, and an attribute at the top of its lib.rs, just after the doc comment, makes that choice permanent: #![forbid(unsafe_code)].

Intuition

Safe Rust is a building where every door checks your badge. unsafe is a staff-only door: behind it, a few extra rooms open, and in those rooms nobody checks badges for you. You sign the logbook on the way in, and whatever happens in there is on you.

Raw pointers

A reference, &T, is a pointer with promises attached: it is never null, it points at a live T, and the borrowing rules hold. A raw pointer is the same address with the promises removed. You make one with &raw const x or &raw mut x, or from a slice with xs.as_ptr():

let x = 5;
let p: *const i32 = &raw const x;
println!("{}", *p);

Making p is fine. Reading through it is not, outside unsafe (Rust 1.97, trimmed):

error[E0133]: dereference of raw pointer
  is unsafe and requires unsafe function
  or block
= note: raw pointers may be null,
  dangling or unaligned; they can violate
  aliasing rules and cause data races
Try itDereferencing outside unsafe
fn main() {
    let x = 5;
    let p = &raw const x;
    // E0133: needs an unsafe block.
    println!("{}", *p);
}
Open in Rust Playground ↗
Definition

A raw pointer is *const T (read through it) or *mut T (read and write through it). It may be null, dangling or misaligned, it has no lifetime, and copying or dropping it does nothing to the value it points at[3]. Creating, copying and comparing raw pointers is safe; dereferencing one is an unsafe operation.

The run below makes a *const and a *mut to the same variable, which two references could never do, then reads and writes through them:

Try itRaw pointers, made safely, read unsafely
use std::mem::size_of;

fn main() {
    let mut n = 41;
    // Making raw pointers is safe, even
    // a *const and a *mut to one place.
    let r = &raw const n;
    let w = &raw mut n;
    unsafe {
        println!("{} {}", *r, *w);
        *w += 1;
    }
    println!("{n}");

    let z = std::ptr::null::<i32>();
    println!("null? {}", z.is_null());
    let s = size_of::<*const i32>();
    println!("{s} bytes");

    let xs = [1.5, 2.5, 4.0];
    let p = xs.as_ptr();
    // SAFETY: 2 < xs.len(), so p.add(2)
    // stays inside xs.
    let x = unsafe { *p.add(2) };
    println!("{x}");
}
Open in Rust Playground ↗

What unsafe unlocks, and what it doesn’t

The Rust book lists five operations that need an unsafe block or function: dereferencing a raw pointer, calling an unsafe function (such as p.add(i) or xs.get_unchecked(i)), using a static mut, implementing an unsafe trait, and reading a union field[1]. The Reference’s list adds a few rarer ones, such as declaring extern blocks for C code[2].

What unsafe does not do is switch anything off. Types are still checked, and so are borrows. Take a reference into a Vec, push, then use the reference, all inside unsafe, and Rust 1.97 still says (trimmed):

error[E0502]: cannot borrow `v` as
  mutable because it is also borrowed
  as immutable

It adds a warning that the unsafe block is unnecessary.

So the promise is narrow. In the extra rooms, you must make sure that every raw pointer you read is valid and every unsafe function’s rules are kept. Break that, and the result is undefined behaviour. Not a panic: the compiler optimizes on the assumption that it never happens, so anything may follow[4].

Step throughWho checks the read?
let xs = [1.5, 2.5, 4.0];
xs[2]
xs[3]
unsafe { *p.add(2) }
unsafe { *p.add(3) }
A three-element array and the slot after it, read with and without a bounds checkxs (len 3)1.5[0]2.5[1]4.0[2]?past the endchecked by: Rust, at run time
Three f64s, 8 bytes each, side by side. p = xs.as_ptr() points at the first. The slot after the end belongs to something else, or to nothing.
1/6
Definition

An unsafe block marks code where you uphold rules the compiler can’t check. Inside it you may use the unsafe operations; everything else is checked as usual. An unsafe fn is one whose callers must keep a promise, so calling it needs unsafe too. A // SAFETY: comment above the block says what the promise is and why it holds.

Safe on the outside

You rarely want unsafe scattered through a program. The usual shape is a small, safe function with the check inside it and one unchecked step:

if i >= xs.len() {
    return None;
}
// SAFETY: i < xs.len()
Some(unsafe { *xs.get_unchecked(i) })

No caller can make this misbehave, whatever i they pass. That is how Vec, slices and String are built: safe methods over a little audited unsafe code inside std[1]. You will write one in the exercise.

Forbidding it: drip-rs

#![forbid(unsafe_code)] turns the unsafe_code lint up to its strictest level for the whole crate. Any unsafe block is an error, and unlike deny, a #[allow(unsafe_code)] lower down can’t undo it: Rust 1.97 answers error[E0453]: allow(unsafe_code) incompatible with previous forbid.

What did that cost? Not pointers between heap objects: chapter 138’s arenas and Gc<T> handles replaced them. The run-time cost is the checks. read_byte checks ip, and every heap[handle] checks the index and that the slot is still Some, because freed slots become None. To put a number on it, I copied drip-rs, deleted the forbid, and replaced five checked reads with unsafe unchecked ones: read_byte, read_constant, peek, and the arena’s two index methods. Then I timed both builds, alternating, seven runs per program, on the book’s shared 4-vCPU cloud VM (release builds, rustc 1.97). Minimum times in ms, checked / unchecked:

programround 1round 2
fib1400 / 12881404 / 1237
method_call280 / 276314 / 265
properties730 / 655706 / 654
zoo520 / 503506 / 493
instantiation558 / 518557 / 502

The unchecked build was 1 to 16 percent faster on minimums; the medians of round 1 went both ways. A third round, rebuilt with plain rustc -C opt-level=3 while this chapter was checked, gave 4 to 12 percent. Checks that never fail are cheap: the branch predictor learns that the index is always in bounds (chapter 20).

Under the hood

What the checks saved is harder to time but easy to point at. The collector of chapter 58 frees objects, so a missed root leaves a handle to a freed slot. In C that is a use-after-free that reads whatever moved in. In drip-rs, the stress and debug modes never reuse freed slots, so the next heap[handle] finds None and panics with a message. The 95 stress-GC programs run with a collection before every allocation, and a missed root fails loudly. The forbid only covers drip-rs’s own source: Vec and HashMap are unsafe inside std. engine/rust has no unsafe blocks in its crates either, but no forbid, and its dependencies use it: in oxc’s arena allocator, 376 source lines contain the word.

Your turn

ExerciseA safe read on top of a raw pointer

Write read(xs, i), which returns Some(xs[i]) when i is in bounds and None otherwise, without using indexing or get. Read through the raw pointer p instead: p.add(i) is a pointer to element i, and both add and the dereference need unsafe. Check the bounds before the unsafe block, and write a // SAFETY: comment saying why the read is fine.

fn read(xs: &[f64], i: usize)
    -> Option<f64> {
    let p: *const f64 = xs.as_ptr();
    todo!()
}
Open in Rust Playground ↗

What you learned

  • A raw pointer, *const T or *mut T, is an address without a reference’s promises. Making one is safe; dereferencing it is not.
  • unsafe unlocks a few operations, chiefly raw-pointer dereferences and calls to unsafe fns. It switches no checks off.
  • Breaking an unsafe promise is undefined behaviour, not a panic.
  • Keep unsafe small and wrap it in a safe function whose own checks make the promise true, with a // SAFETY: comment.
  • drip-rs’s #![forbid(unsafe_code)] cost it a few percent of run time and bought collector bugs that panic instead of corrupting memory.

Check your understanding

0/6
  1. 1.

    Which line needs an unsafe block?

  2. 2.

    Inside unsafe { ... } you take &v[0] and then call v.push(4) while the reference is still used. What happens?

  3. 3.

    What is true of a *const f64?

  4. 4.

    drip-rs’s lib.rs has #![forbid(unsafe_code)] at the top. Someone adds #[allow(unsafe_code)] on one function. What happens?

  5. 5.

    In this chapter’s experiment, replacing five checked reads in drip-rs with unchecked ones made it…

  6. 6.

    What is a “safe abstraction” over unsafe code?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] The Rust Reference contributors. Unsafety. The Rust Reference. doc.rust-lang.org/reference/unsafety.html (checked 2026-10-31)
  3. [3] The Rust Reference contributors. Pointer types: raw pointers (*const and *mut). The Rust Reference. doc.rust-lang.org/reference/types/pointer.html#raw-pointers-const-and-mut (checked 2026-10-31)
  4. [4] The Rust Reference contributors. Behavior considered undefined. The Rust Reference. doc.rust-lang.org/reference/behavior-considered-undefined.html (checked 2026-10-31)