What Is Undefined Behavior in Rust?

Undefined behavior in Rust is unpredictable program execution caused by violating memory safety rules, often occurring in unsafe code blocks.

The contract breaks

You write a function that sorts a list. It compiles. You run it. The first time, it works. The second time, it crashes. The third time, it prints your password to the terminal. You check the code. It looks fine. The compiler gave no errors. The logic is sound. But the program is doing things that make no sense.

This isn't a bug in your logic. This is undefined behavior. The compiler has stopped guaranteeing anything about what happens next.

The promise and the penalty

Rust treats memory safety like a contract. In safe Rust, the compiler checks every rule before the program runs. If you break a rule, the code doesn't compile. You cannot access freed memory. You cannot have a mutable reference and an immutable reference to the same data at the same time. You cannot dereference a null pointer. The compiler enforces these rules statically.

unsafe tells the compiler, "I promise I'm following the rules, but you can't check them."

If you keep that promise, the code runs fast and safely. The compiler trusts you and generates efficient machine code. If you break the promise, the compiler assumes you're right and optimizes based on that assumption. When your promise turns out to be false, the program enters undefined behavior.

The compiler might have removed a check it thought was impossible. It might have reordered memory writes. It might have assumed a pointer is valid and skipped a null check. The result is chaos. The program doesn't just fail. It does whatever the optimizer decided was consistent with the broken promise.

Undefined behavior isn't a runtime error. It's a hole in the foundation. The compiler builds the house assuming the foundation holds. If it doesn't, the house doesn't just leak. It vanishes.

Minimal example: the null pointer

The simplest form of undefined behavior is dereferencing a null pointer. In safe Rust, this is impossible. References cannot be null. In unsafe, you can create a null pointer and dereference it.

fn main() {
    // Create a pointer that points to address zero.
    // Address zero is reserved by the operating system and never valid for user data.
    let ptr = std::ptr::null::<i32>();

    // Dereferencing a null pointer is undefined behavior.
    // Rust does not trap on null access like some languages.
    // The compiler assumes this line is unreachable and may delete it entirely.
    let value = unsafe { *ptr };

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

If you try to dereference a raw pointer in safe code, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This is a compile-time guard. Undefined behavior happens when you bypass this guard and lie to the compiler.

What the optimizer does

The key insight about undefined behavior is how the compiler uses it during optimization. The compiler treats undefined behavior as impossible. If your code contains a path that triggers undefined behavior, the compiler assumes that path never executes.

Consider this function:

fn process(ptr: *const i32) -> i32 {
    unsafe {
        // If ptr is null, this is undefined behavior.
        // The compiler assumes ptr is not null because null dereference is UB.
        let val = *ptr;

        // The compiler knows ptr cannot be null.
        // This check is dead code. The optimizer removes the entire branch.
        if ptr.is_null() {
            return 0;
        }

        val
    }
}

You might expect this function to return 0 when passed a null pointer. It doesn't. The compiler sees *ptr and knows that if ptr were null, the program would have undefined behavior. Since undefined behavior is impossible, ptr cannot be null. The compiler deletes the null check. When you call this with a null pointer, the program crashes or returns garbage. The check you wrote to save you is gone.

This is why undefined behavior is so dangerous. It doesn't just cause crashes. It silently deletes your safety checks. The code looks correct. The logic seems sound. But the optimizer has rewritten your program based on a lie.

Test in release mode. Debug mode hides undefined behavior that release mode will exploit.

Realistic example: data races

The most common source of undefined behavior in real code is data races. A data race occurs when two threads access the same memory location concurrently, at least one access is a write, and there is no synchronization.

Rust's type system prevents data races in safe code. You cannot share mutable state between threads without using Send and Sync types, which enforce synchronization. In unsafe, you can bypass these checks.

// A global mutable variable.
// Accessing this from multiple threads without synchronization is undefined behavior.
static mut DANGEROUS_COUNTER: u32 = 0;

/// SAFETY: This function must only be called from a single thread.
/// Calling this from multiple threads creates a data race.
/// A data race is undefined behavior in Rust.
unsafe fn unsafe_increment() {
    // The compiler assumes no other thread is touching this memory.
    // It may optimize this read/write into a register that never syncs.
    DANGEROUS_COUNTER += 1;
}

// GOOD: Atomic guarantees synchronization.
use std::sync::atomic::{AtomicU32, Ordering};

static SAFE_COUNTER: AtomicU32 = AtomicU32::new(0);

fn safe_increment() {
    // Atomic operations enforce memory ordering.
    // The compiler cannot reorder these past other memory accesses.
    SAFE_COUNTER.fetch_add(1, Ordering::SeqCst);
}

In the unsafe_increment function, the compiler assumes exclusive access to DANGEROUS_COUNTER. It might load the value into a CPU register, increment the register, and never write it back to memory. If another thread is also incrementing the counter, the updates disappear. Worse, the compiler might reorder memory accesses in ways that corrupt unrelated data.

The // SAFETY: comment is a proof. It lists the invariants the caller must uphold. If you can't write the comment, you don't understand the safety requirements.

Reach for AtomicU32. The performance cost is negligible, and the safety guarantee is priceless.

Pitfalls and hidden traps

Undefined behavior hides in places you don't expect. Here are the common traps.

Aliasing violations. Rust has strict aliasing rules. You cannot have a &mut T and a &T to the same data at the same time. If you use raw pointers to create both, you violate the aliasing rules. This is undefined behavior. The compiler may assume no aliasing exists and reorder memory accesses, causing your data to corrupt silently.

fn aliasing_bug(vec: &mut Vec<i32>) {
    let first = vec.get_mut(0).unwrap();
    // first is a mutable reference to vec[0].
    // vec is still borrowed mutably.
    
    // This creates a shared reference to vec[0] while first exists.
    // This violates the aliasing rules.
    let second = &vec[0];
    
    // Using both is undefined behavior.
    *first = 10;
    println!("{}", second);
}

If you try to create conflicting borrows in safe code, the compiler rejects you with E0502 (cannot borrow as mutable because it is also borrowed as immutable). In unsafe, you can bypass this check. The compiler assumes the borrows don't overlap and optimizes aggressively.

Out-of-bounds access via raw pointers. Safe Rust checks array bounds at runtime. Raw pointers do not. Dereferencing a pointer outside the allocated memory is undefined behavior.

fn oob_example(vec: &Vec<i32>) -> i32 {
    let ptr = vec.as_ptr();
    // vec has 5 elements. Index 10 is out of bounds.
    // This is undefined behavior.
    unsafe { *ptr.add(10) }
}

The compiler may assume the pointer is within bounds and generate code that reads adjacent memory. It might read sensitive data, crash, or return garbage. The behavior depends on the memory layout, which can change between compiler versions or optimization levels.

Dangling pointers. Dropping a value while a raw pointer still points to it is undefined behavior. The memory is freed, but the pointer still exists. Dereferencing the pointer accesses freed memory.

fn dangling_example() -> *const i32 {
    let data = vec![1, 2, 3];
    let ptr = data.as_ptr();
    // data is dropped here. The memory is freed.
    // ptr now points to freed memory.
    ptr
}

Using the returned pointer is undefined behavior. The memory might be reused for something else. You might overwrite critical data or read stale values.

Convention aside: Every unsafe block or function needs a // SAFETY: comment. This comment is a proof. It lists the invariants the caller must uphold. The community standard is to write the comment before writing the code. If you can't write the comment, you don't have a safe abstraction.

Write the safe wrapper. If you can't write the safe wrapper, you don't have a safe abstraction.

Decision: when to use what

Undefined behavior is the price of unsafe. You pay that price only when you have no other choice. Use the right tool for the job.

Use safe Rust for application logic, data processing, and business rules. The borrow checker handles the complexity for you. Use unsafe with raw pointers when you are building a low-level abstraction like a Vec, a hash map, or a lock-free queue. Wrap the unsafe code in a safe API so callers cannot trigger undefined behavior. Use unsafe for FFI when you interface with C libraries or system calls. Verify the foreign code's memory guarantees before crossing the boundary. Use Atomic* types for simple counters and flags shared between threads. Use Mutex or RwLock for complex shared state where you need to hold multiple values consistently. Avoid static mut entirely. It offers no synchronization and invites undefined behavior.

Trust the borrow checker. It usually has a point.

Where to go next