25.3Unsafe functions and safe abstractions

Last updated June 13, 2026

Raw pointers (lesson 25.2) are dangerous in the open, but the Rust way is never to leave them in the open. The whole craft of using unsafe well is to confine it: write a tiny core of unsafe code, prove it correct, and wrap it in a safe abstraction, a normal, safe function that callers use without ever touching unsafe themselves. This lesson covers unsafe functions and the wrapping pattern that makes them livable, with the standard library's own split_at_mut as the canonical example. This is the most important idea in the chapter: unsafety, done right, doesn't spread.

Unsafe functions

A function can be marked unsafe, which means it has preconditions the caller must satisfy for it to behave correctly, conditions the compiler can't check. Calling such a function requires an unsafe block, your acknowledgment that you've upheld its contract:

unsafe fn dangerous() {
    // ... does something that's only safe under certain conditions
}

fn main() {
    unsafe {
        dangerous();   // calling an unsafe fn requires unsafe
    }
}

The unsafe on the function definition is a warning label: "this function trusts you to have met its requirements; if you haven't, it can cause undefined behavior." The unsafe block at the call site is you signing for that. Marking a function unsafe doesn't make its body specially permissive in any new way, it's a signal to callers about the contract they're taking on. Standard-library functions like slice::get_unchecked (index without bounds-checking) are unsafe precisely because they trust the caller to pass a valid index; pass a bad one and it reads out of bounds.

The pattern: a safe wrapper around unsafe code

Here's the move that makes unsafe manageable. Most functions that contain unsafe code are not themselves unsafe. They wrap a small unsafe core inside a safe function that guarantees the preconditions are met, so callers get a completely safe interface. If the wrapper genuinely upholds every invariant the unsafe code needs, then no matter what the caller does, nothing can go wrong, and the function is safe to expose.

This is exactly how the standard library is built. Vec, String, every collection, contains unsafe raw-pointer code internally, wrapped in the safe API you've used for chapters without ever writing unsafe yourself. You've been standing on a foundation of audited unsafe the whole time, and never knew, because the abstractions are airtight.

split_at_mut: the canonical example

The textbook case is split_at_mut, a real slice method that splits one mutable slice into two mutable halves. Think about why this is hard for the borrow checker. You want two &mut slices into the same original slice at once, which sounds like an instant violation of chapter 9's "only one mutable borrow" rule. But it's actually safe: the two halves cover disjoint parts of the slice, no overlap, so there's no aliasing. The trouble is the borrow checker can't see that they're disjoint; it just sees two mutable borrows of one slice and refuses.

So the implementation drops to unsafe for the part the checker can't verify, and wraps it safely:

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();

    assert!(mid <= len);   // precondition: mid must be in range

    unsafe {
        // SAFETY: `mid <= len` (asserted above), so the two ranges
        // [0, mid) and [mid, len) are disjoint and both within the
        // slice's allocation. Disjoint ranges means the two &mut
        // slices never alias, upholding the borrow rules.
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut numbers = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut numbers, 3);
    left[0] = 100;
    right[0] = 200;
    println!("{numbers:?}");
}
[100, 2, 3, 200, 5, 6]

Look at what makes this sound. The assert!(mid <= len) guarantees the split point is in range, before any unsafe code runs, so a caller can't ask for an out-of-bounds split. Inside unsafe, from_raw_parts_mut builds two slices from raw pointers: one covering [0, mid), one covering [mid, len). Because mid <= len, those ranges are disjoint, they share no element, so the two &mut slices never point at the same memory, and the borrow rule isn't actually violated, only the compiler's ability to prove it was. The // SAFETY: comment records exactly this reasoning.

And crucially: split_at_mut itself is not unsafe. A caller uses it like any normal function, let (left, right) = split_at_mut(&mut numbers, 3), with no unsafe block, no danger. The unsafety was real but it's been fully contained: bounded by the assert, justified by the disjointness argument, and sealed inside a safe interface. The caller cannot misuse it, because the wrapper checks everything that could go wrong.

Key insight

The goal of unsafe is never to expose it, it's to bury it. You write the smallest possible unsafe core, prove (and comment) that it's sound for all inputs the wrapper allows, enforce any preconditions with checks like assert!, and present callers a fully safe function. Done right, unsafety doesn't propagate: a million lines of safe code can sit atop a few audited unsafe blocks and inherit none of the risk. That's the entire reason Rust can have a safe standard library built on raw pointers. Unsafe is a sealed core, not a spreading stain.

The module boundary as a safety argument

There's a subtle reason the wrapper has to be trustworthy: a safe function makes a promise to the whole program that it can't cause undefined behavior however it's called. So when you wrap unsafe in a safe API, you're asserting that the abstraction is sound for every possible input and use. That's a real proof obligation, and it's why the safe boundary usually sits at a module or function edge where you can reason about all the ways the code can be used. If even one input could make the unsafe core misbehave, the function should not be marked safe, it should stay unsafe and push the obligation to its callers. Drawing that boundary honestly, safe only when it's truly safe for all inputs, is the discipline that keeps the whole edifice standing.

Quiz time

Question #1

What does it mean for a function to be marked unsafe, and what's required to call it?

Show solution

It means the function has preconditions the caller must uphold (which the compiler can't check) for it to behave correctly, calling it incorrectly can cause undefined behavior. The unsafe on the definition is a warning label about that contract. To call it, you must use an unsafe block, signaling that you've taken responsibility for meeting its requirements (e.g. get_unchecked trusts you to pass an in-bounds index).

Question #2

Why is split_at_mut able to be a safe function even though it uses unsafe internally?

Show solution

Because the wrapper guarantees every precondition the unsafe code needs, for all inputs. It assert!s mid <= len before the unsafe block, and the two resulting slices cover disjoint ranges ([0, mid) and [mid, len)), so they never alias, the borrow rule isn't actually violated, only un-provable by the compiler. Since no caller input can make the unsafe core misbehave, the function is safe to expose, and callers use it with no unsafe. The unsafety is fully contained.

Question #3

What proof obligation do you take on when you wrap unsafe code in a safe function?

Show solution

You're promising the whole program that the function cannot cause undefined behavior no matter how it's called, i.e. that the unsafe core is sound for every possible input and use the safe interface permits. If even one input could make it misbehave, it must not be marked safe (it should stay unsafe and push the obligation to callers). Drawing that boundary honestly, safe only when truly safe for all inputs, is what keeps a safe API built on unsafe code trustworthy.

You can now contain unsafety behind safe interfaces. The remaining two lessons apply this to the biggest real-world use of unsafe: talking to other languages. The next lesson (25.4) covers calling C functions from Rust, extern "C", linking, and the unsafe boundary that crossing into another language always is.