How to Call Unsafe Functions in Rust

You call unsafe functions in Rust by wrapping the function call inside an `unsafe` block, which explicitly tells the compiler you have manually verified that the operation is safe to perform.

When the compiler says no, but you know better

You're building a high-performance parser. You've got a buffer of bytes. You want to read a 32-bit integer directly from a specific offset without copying. You write the code. The compiler screams. It refuses to let you dereference a raw pointer. You know the pointer is valid. You know the memory is aligned. You know what you're doing. The compiler just doesn't.

This is the moment you reach for unsafe.

Rust's ownership model is a set of rules that guarantee memory safety and thread safety. The compiler enforces these rules at compile time. The unsafe keyword tells the compiler to pause enforcement for a specific block. It does not disable the rules. It shifts the burden of proof to you. Inside an unsafe block, the compiler assumes you have verified every safety invariant manually. If you are wrong, you get undefined behavior. The program might crash, corrupt data, or look like it works until three weeks later when it silently deletes a database.

The safety inspector and the master key

Think of the Rust compiler as a relentless safety inspector. It checks every door, every wire, every load-bearing wall. It guarantees that no two people can write to the same memory at the same time. It guarantees that you never read memory that has been freed.

The unsafe block is a room where the inspector turns off the camera. You can do whatever you want in that room. You can wire the electricity yourself. You can remove the guardrails. The inspector won't stop you. But if you trip a wire, the inspector won't save you. You are now the inspector.

The unsafe keyword is a promise. It's a zero-cost abstraction. There is no runtime overhead. The binary runs exactly as fast as the equivalent C code. The compiler does not generate checks. It trusts you. If you lie, the consequences are undefined behavior. Undefined behavior means the compiler can assume the code never happens. This leads to optimizations that break your program in ways that defy logic. The compiler might delete a null check because it assumes the pointer is never null. If it is null, the program has already violated the contract, and the compiler's assumptions are invalid.

Minimal example: dereferencing a raw pointer

The most common trigger for unsafe is dereferencing a raw pointer. Rust references (&T and &mut T) are always valid. Raw pointers (*const T and *mut T) can be null, dangling, or misaligned. The compiler forbids dereferencing raw pointers outside an unsafe block.

/// Demonstrates calling an unsafe operation to dereference a raw pointer.
fn main() {
    let x = 42;
    // Create a raw pointer by casting a reference.
    // This bypasses the borrow checker's tracking.
    // The pointer is valid because x is alive.
    let ptr = &x as *const i32;

    // The compiler forbids dereferencing raw pointers outside unsafe.
    // E0133: dereference of raw pointer requires unsafe function or block.
    // We wrap the dereference in unsafe to acknowledge the risk.
    let value = unsafe {
        *ptr
    };

    println!("Value: {}", value);
}

If you remove the unsafe block, the compiler rejects the code with E0133. This error is a safeguard. It forces you to think. If you see E0133, ask yourself whether you really need unsafe. Often, you don't. Maybe a safe API exists. If you suppress E0133, you are taking on the risk of undefined behavior.

Community convention demands you keep unsafe blocks as small as possible. This is the "minimum unsafe surface" rule. Wrap only the specific operation that requires unsafe, not the whole function. If you can isolate the risk to one line, do it. It makes the code easier to audit. Reviewers scan unsafe blocks for correctness. A small block is easier to verify.

Walk-through: what happens at compile time

At compile time, the compiler sees the unsafe block and suspends its safety checks for the contents. It does not check for null pointers. It does not check for aliasing. It does not check for alignment. It assumes you have done the checking.

The compiler still checks syntax and types. You can't dereference a pointer to a u32 and assign it to a String. Type safety remains enforced. Only safety checks are relaxed.

The unsafe keyword vanishes in the binary. There is no runtime cost. The generated machine code is identical to what you would write in C. The burden shifts entirely to you. You must ensure the preconditions hold. If you dereference a null pointer inside unsafe, the compiler won't stop you. The CPU will raise a segmentation fault. If you have a data race, the compiler won't stop you. The program might corrupt memory in ways that are impossible to reproduce.

Realistic example: building a safe wrapper

The best use of unsafe is building a safe abstraction. You write a function that uses unsafe internally, but the public API is safe. The function checks all preconditions before entering the unsafe block. The caller gets safety without knowing about the raw pointers.

/// Reads a u32 from a byte slice at a given offset.
/// Panics if the offset is out of bounds or unaligned.
fn read_u32_at(data: &[u8], offset: usize) -> u32 {
    // Validate bounds before entering unsafe.
    // The safe wrapper enforces preconditions.
    // If this check fails, we panic instead of UB.
    if offset + 4 > data.len() {
        panic!("Offset out of bounds");
    }

    // Check alignment for the target type.
    // Unaligned access can cause crashes on some architectures.
    // Some CPUs support unaligned access, but it's slower.
    // Rust requires alignment for dereferencing.
    if (offset % 4) != 0 {
        panic!("Unaligned access");
    }

    // SAFETY:
    // 1. We checked bounds: offset + 4 <= data.len().
    // 2. We checked alignment: offset % 4 == 0.
    // 3. data is a valid slice, so the pointer is non-null and valid.
    // 4. No mutable aliasing: data is immutable, so no data races.
    unsafe {
        let ptr = data.as_ptr().add(offset) as *const u32;
        *ptr
    }
}

fn main() {
    let data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
    let value = read_u32_at(&data, 0);
    println!("Value: {}", value);
}

Every unsafe block needs a // SAFETY: comment. This is not optional in professional Rust code. The comment lists the invariants that make the block safe. It's a proof. If you can't write the proof, you don't have a safe block. The community treats the // SAFETY: comment as a contract. Reviewers will reject PRs without it. The comment should list numbered invariants. It should reference the checks that enforce those invariants. It should be concise and precise.

Convention aside: use let _ = ... to discard a result when you call an unsafe function that returns a value you don't need. It signals to readers that you considered the value and chose to drop it. It prevents warnings and clarifies intent.

FFI: calling C code

Foreign Function Interface (FFI) is the most common reason to use unsafe. When you call C code, you cross out of Rust's safety guarantees. The C function might dereference a null pointer. It might modify memory Rust thinks is immutable. The compiler cannot verify the C code. You must wrap the call in unsafe.

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

extern "C" {
    fn printf(format: *const i8, ...) -> c_int;
}

/// Prints a message using C's printf.
/// SAFETY: The caller must ensure the format string is valid.
unsafe fn print_hello() {
    // Create a null-terminated byte string.
    // C expects strings to end with a null byte.
    let msg = b"Hello from Rust!\0";

    // SAFETY:
    // 1. msg is a valid, null-terminated string.
    // 2. printf is a standard C function that expects this format.
    // 3. No variadic arguments are passed, so no type mismatch.
    unsafe {
        printf(msg.as_ptr() as *const i8);
    }
}

fn main() {
    // The wrapper is unsafe because it calls an unsafe function.
    // The caller must trust the wrapper's safety proof.
    unsafe {
        print_hello();
    }
}

The extern "C" block declares the C function. The compiler assumes the function exists and has the correct signature. If the signature is wrong, you get undefined behavior. The unsafe block around the call acknowledges the risk. The // SAFETY: comment lists the invariants. In this case, the invariants are about the format string and the arguments.

Convention aside: when calling FFI, use bindgen to generate the bindings. Hand-writing extern "C" blocks is error-prone. bindgen reads the C headers and generates Rust code. It reduces the chance of signature mismatches. You still need unsafe to call the generated functions, but the bindings themselves are safer.

Pitfalls and compiler errors

The compiler error E0133 is your friend. It stops you from accidentally dereferencing raw pointers. It forces you to think. If you see E0133, ask yourself whether you really need unsafe. Often, you don't. Maybe get_unchecked is too aggressive. Maybe a safe API exists. If you suppress E0133, you are taking on the risk of undefined behavior.

Undefined behavior is the enemy. It means the compiler can assume the code never happens. This leads to optimizations that break your program in subtle ways. The compiler might delete a null check because it assumes the pointer is never null. If it is null, the program has already violated the contract, and the compiler's assumptions are invalid. The binary might do anything. It might crash. It might corrupt data. It might look like it works.

Mutable aliasing is another pitfall. Rust forbids multiple mutable references to the same memory. unsafe allows it. If you have two &mut to the same place, and you write through both, the order is undefined. The compiler might cache a value in a register and never reload it. You might write a value, then read the old cached value. The program behaves incorrectly.

Data races are a third pitfall. Rust's type system prevents data races. unsafe allows you to create data races. If two threads write to the same memory without synchronization, you have a data race. The program has undefined behavior. The compiler might optimize away a lock because it assumes no data race exists. The binary might crash or corrupt data.

Decision: when to use unsafe

Use unsafe for FFI when you call C or another language and have to cross out of Rust's safety guarantees. Use unsafe for performance-critical inner loops where measured profiling shows safe abstractions are the bottleneck, and you can prove the unsafe path is correct. Use unsafe when you're implementing a safe abstraction yourself, like a custom allocator, a Vec-like structure, or a concurrent queue. Reach for safe APIs when the compiler accepts your code; the performance difference is usually negligible, and safety is free. Reach for get_unchecked only when you have a mathematical proof of bounds; otherwise, use get or get_mut. Reach for RefCell when you need interior mutability and can tolerate the runtime check.

Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. unsafe blocks are islands of trust in a sea of verification. Keep the islands small. Treat the // SAFETY: comment as a proof. If you can't write it, you don't have one.

Where to go next