How to Use Miri to Detect Undefined Behavior

Miri is an interpreter for Rust that detects undefined behavior (UB) by running your code in a controlled environment rather than on native hardware.

When native execution isn't enough

You spent three hours debugging a segmentation fault that only appears when you run your binary on a different machine. The code looks correct. The borrow checker passed. You even wrote unsafe blocks with careful comments. Yet the program crashes with a memory access violation that makes no sense. This is the nightmare of undefined behavior. Your code compiles, runs, and then silently corrupts memory or crashes in ways that depend on the phase of the moon.

Miri is the tool that stops this before it happens. It interprets your Rust code instruction by instruction, checking every memory access and pointer operation against the rules of the language. If you break a rule, Miri halts immediately and tells you exactly where. You don't get a crash in production. You get a precise error report during development.

Miri catches these errors before they ship. Run your code through the simulator.

How Miri works

Rust's compiler generates native machine code. It trusts you. When you write unsafe, the compiler assumes you know what you're doing and generates instructions that might crash if you're wrong. The compiler also performs optimizations based on the assumption that undefined behavior never occurs. If you have UB, the compiler might reorder memory accesses, delete checks, or transform loops in ways that break your logic. The resulting binary is unpredictable.

Miri takes a different approach. It doesn't generate machine code. It runs your code inside a virtual machine that enforces every rule of Rust's memory model. Think of Miri as a flight simulator for your code. When you compile with rustc, you get a real plane. If you pull the stick too hard, the plane might break apart. Miri is the simulator. It models the physics of Rust's memory rules. If you try to read memory you don't own, or dereference a null pointer, the simulator flags the violation instantly. You don't get a crash hours later. You get a red card right at the moment you break the rule.

Miri requires no changes to your source code. You don't add attributes or macros. You just switch the runner. This zero-intrusion design makes it easy to add to existing projects. You run cargo miri test instead of cargo test, and Miri interprets your tests. The community convention is to run Miri on the nightly toolchain, as it tracks the latest language semantics.

The referee blows the whistle instantly. You fix the rule violation while the context is fresh.

Minimal example

Miri detects undefined behavior by simulating memory allocations and tracking pointer bounds. When you create a value, Miri records its size and location. When you perform pointer arithmetic, Miri checks if the result stays within valid bounds. When you dereference a pointer, Miri verifies that the pointer is valid and aligned.

/// Demonstrates out-of-bounds pointer arithmetic caught by Miri.
fn main() {
    let data = [10, 20, 30];
    let ptr = data.as_ptr();
    
    // Miri tracks the bounds of every allocation.
    // Adding 10 to a pointer to a 3-element array goes out of bounds.
    let bad_ptr = ptr.add(10);
    
    // SAFETY: This is intentionally unsafe to demonstrate Miri.
    // In real code, you must prove the pointer is valid.
    // Miri will catch this violation immediately.
    let val = unsafe { *bad_ptr };
    
    println!("{}", val);
}

When you run cargo miri run, Miri loads your code. It simulates the allocation of the array. It tracks that the array occupies exactly three integers. When it hits ptr.add(10), it calculates the new address. It checks if that address falls within the tracked bounds. It doesn't. Miri aborts. You get an error message pointing to the line. No crash. No segfault. Just a clear report.

Miri halts execution and points to the line. Fix the pointer arithmetic before it reaches production.

Realistic scenario

Miri is especially valuable for multithreaded code. Data races are a common source of undefined behavior. 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. The compiler cannot detect data races in unsafe code. Miri can.

use std::thread;
use std::sync::Arc;

/// Shows a data race using raw pointers.
/// Miri detects this because threads access shared memory
/// without synchronization.
fn main() {
    let data = Arc::new(42);
    
    // Leak the Arc to get a raw pointer.
    // This bypasses the borrow checker.
    let raw_ptr = Arc::into_raw(Arc::clone(&data));
    
    let t1 = thread::spawn(move || {
        // SAFETY: This is unsafe. We are writing to shared memory
        // from multiple threads without synchronization.
        // Miri will flag this as a data race.
        unsafe {
            *raw_ptr = 100;
        }
    });
    
    let t2 = thread::spawn(move || {
        // SAFETY: Same issue here. Reading shared memory
        // while another thread might be writing.
        unsafe {
            let val = *raw_ptr;
            println!("Thread 2 saw: {}", val);
        }
    });
    
    t1.join().unwrap();
    t2.join().unwrap();
}

Miri simulates thread scheduling. It explores different interleavings of thread execution. When it detects that two threads access the same memory without synchronization, it reports a data race. The error message includes the stack traces of both threads, making it easy to locate the source of the conflict.

Miri detects the race condition immediately. Add synchronization or use atomic operations to resolve the conflict.

What Miri catches

Miri checks a wide range of undefined behavior patterns. It catches null pointer dereferences when you cast a zero integer to a pointer and dereference it. Miri catches out-of-bounds access when you index past the end of a slice. Miri catches data races when threads access shared memory without synchronization. Miri catches use-after-free when you drop a value and then access it through a raw pointer. Miri catches uninitialized memory reads when you read from memory that hasn't been written to. Miri catches invalid alignment when you dereference a pointer that isn't aligned for the type.

Miri also catches subtle issues in safe code. Some safe wrappers hide unsafe implementations. If the wrapper violates invariants, Miri catches the UB even though the call site looks safe. This helps library authors verify their abstractions.

Convention aside: Miri requires a sysroot to run. The sysroot contains the standard library compiled for Miri. You need to run cargo miri setup once to download it. This command fetches the necessary files and configures your environment. If you skip this step, Miri fails with a setup error.

Convention aside: You can control Miri's behavior with environment variables. The community often uses MIRIFLAGS="-Zmiri-ignore-leaks" for tests that intentionally leak memory, such as caches or long-running servers. This flag tells Miri to ignore memory leaks at the end of the test. Use it sparingly. Leaks are still bugs in most cases.

Miri verifies your abstractions down to the metal. Trust the interpreter to find the holes.

Limitations and pitfalls

Miri has limitations. It runs significantly slower than native execution. Miri simulates every instruction, including memory accesses and system calls. A test that takes milliseconds natively might take seconds or minutes under Miri. This overhead makes Miri unsuitable for performance testing. Use Miri for correctness, not speed.

Miri requires the nightly toolchain. You must install the miri component and use cargo +nightly miri. If you try to run Miri on stable, you get a toolchain error. This means Miri tracks the latest language features, but it might break if nightly changes. The community convention is to pin Miri to a specific nightly version in CI to avoid breakage.

Miri cannot check external libraries. When you call C code through FFI, Miri mocks the call. It doesn't execute the C code. This means Miri might miss UB inside C code. It also means Miri might miss UB caused by C code if the mock doesn't match reality. Miri trusts your FFI mocks. If you write a mock that returns invalid data, Miri might not catch it. You need to verify C behavior separately with sanitizers like AddressSanitizer.

Miri simulates the environment. It mocks file system access, network calls, and process management. If your code relies on specific OS behavior that Miri doesn't mock, you might get false positives. You can disable isolation with MIRIFLAGS="-Zmiri-disable-isolation" to allow real system calls, but this reduces Miri's ability to detect UB.

Miri trusts your FFI mocks. Verify C behavior separately with sanitizers.

When to use Miri

Use Miri when you write unsafe code and need to verify pointer arithmetic and memory safety. Use Miri when you suspect a data race in multithreaded code that the compiler cannot prove is safe. Use Miri when you are building low-level abstractions like allocators or data structures that expose raw pointers. Reach for standard tests when your code is purely safe and you want fast feedback on logic errors. Reach for sanitizers like AddressSanitizer when you need to check C interop or run on production binaries where Miri's overhead is too high. Pick Miri for CI when you can afford the runtime cost to catch UB before it merges.

Integrate Miri into your CI pipeline. Catch undefined behavior before it merges into main.

Where to go next