The bug that vanishes when you look at it
You have a function that processes a byte buffer. It passes every test. It runs fast. Then a user reports a crash that happens only on Tuesdays, or only when the load is high. You stare at the code. The logic looks solid. The compiler didn't complain. You add a println! and the crash disappears. You're dealing with undefined behavior. The CPU is doing something the Rust spec doesn't guarantee, and the result depends on the wind direction.
Rust's compiler is a contract enforcer. It checks that you followed the rules of ownership and borrowing. If you pass those checks, the compiler assumes you're safe. It generates machine code that runs at full speed. But the compiler trusts you. If you step into unsafe, or if you write code that technically follows the borrow checker but still does something illegal like dereference a dangling pointer, the compiler might let it slide. The generated code could crash, corrupt memory, or appear to work perfectly until the optimizer changes its mind next week.
Miri is a runtime simulator. It doesn't generate machine code. It executes your Rust code instruction by instruction inside a virtual machine. That virtual machine enforces every single rule of the language, including the ones the compiler skips for performance. It's the difference between a proof that a bridge is safe and actually driving a truck over it to see if it holds.
What Miri actually does
Miri interprets the LLVM IR produced by rustc. It maintains a detailed model of memory, threads, and system state. Every allocation gets a tag. Every pointer gets provenance, which tracks where the pointer came from. When your code dereferences a pointer, Miri checks if the pointer is valid, aligned, and points to live memory. When your code accesses shared data from multiple threads, Miri checks for data races by exploring different execution interleavings.
Miri catches undefined behavior that the compiler cannot detect. This includes invalid pointer dereferences, use-after-free, buffer overflows, data races, and violations of pointer provenance. It also catches some safe Rust bugs, like out-of-bounds access, though the compiler often catches those too. Miri is the ultimate verifier for unsafe code. If your code passes Miri, you have strong evidence that it doesn't contain undefined behavior.
Run Miri as a Cargo subcommand. It integrates seamlessly with your build workflow. You can run your binary, your tests, or your benchmarks under Miri. The syntax mirrors standard Cargo commands.
# Install the Miri component if you haven't already.
rustup component add miri
# Run your main binary under Miri.
cargo miri run
# Run your test suite under Miri.
cargo miri test
Convention aside: The community treats cargo miri test as the gold standard for validating unsafe code. Run it in CI on your test suite. If a test is too slow, run Miri on a subset of tests that exercise the critical paths.
Minimal example: the dangling pointer
Start with a classic mistake. You take a pointer to a vector's data, then modify the vector. The vector might reallocate, invalidating the pointer. Dereferencing the old pointer is undefined behavior. The compiler allows this inside unsafe. Miri catches it.
/// Demonstrates a dangling pointer caused by reallocation.
fn main() {
let mut v = vec![1, 2, 3];
// Get a raw pointer to the first element.
let ptr = v.as_ptr();
// Pushing might reallocate the vector's buffer.
// If it reallocates, `ptr` points to freed memory.
v.push(4);
// Dereferencing `ptr` here is undefined behavior.
// The compiler allows this because we're in `unsafe`.
// Miri will catch it.
unsafe {
println!("Value: {}", *ptr);
}
}
Run this with cargo miri run. Miri simulates the allocation. It sees the push. It simulates the reallocation. It marks the old buffer as deallocated. The *ptr dereference hits that deallocated region. Miri stops execution and prints a report.
error: Undefined Behavior: pointer to alloc1234 was dereferenced, but that memory has been deallocated
--> src/main.rs:14:28
|
14 | println!("Value: {}", *ptr);
| ^^^^^ pointer to this allocation was dereferenced
The report points exactly to the line. It tells you what went wrong. It gives you the stack trace. You fix the bug by moving the dereference before the push, or by using a safe abstraction that prevents the mistake.
Don't guess about reallocation. Let Miri tell you when your pointer assumptions break.
The hidden trap: pointer provenance
Miri tracks pointer provenance. This is a subtle rule that catches bugs even experienced Rustaceans miss. Every pointer in Rust has provenance, which links it to the allocation it points to. Integers don't have provenance. If you cast a pointer to an integer and back, you lose the provenance. Dereferencing the resulting pointer is undefined behavior, even if the address is correct.
This matters when you serialize pointers, store them in integers, or do pointer arithmetic on integers. Miri enforces provenance strictly. It catches bugs where you assume an integer address is enough to reconstruct a valid pointer.
/// Demonstrates a provenance loss bug.
fn main() {
let x = 5;
// Get a pointer and cast it to an integer.
let ptr = &x as *const i32;
let addr = ptr as usize;
// Cast the integer back to a pointer.
// This loses provenance. The new pointer is invalid in Miri.
let ptr2 = addr as *const i32;
// Dereferencing `ptr2` is undefined behavior.
// The address is correct, but the provenance is gone.
unsafe {
println!("Value: {}", *ptr2);
}
}
Run this with cargo miri run. Miri rejects the dereference.
error: Undefined Behavior: attempting a read access on alloc1234, but the provenance of the pointer does not permit this access
--> src/main.rs:14:28
|
14 | println!("Value: {}", *ptr2);
| ^^^^^ attempting a read access here
The fix is to avoid casting pointers to integers unless you have a specific reason and you restore provenance using ptr::with_exposed_provenance or similar APIs. Most of the time, you don't need to cast pointers to integers. Keep pointers as pointers.
Miri catches the bugs that hide behind correct addresses. Provenance is not optional.
Realistic example: data races in unsafe code
Data races are the killer in concurrent code. Two threads access the same memory, at least one writes, and there's no synchronization. This is undefined behavior. Safe Rust prevents data races by design. unsafe code can introduce them. Miri detects data races by simulating thread scheduling and exploring interleavings.
/// Simulates a race condition in a multi-threaded worker.
use std::sync::Arc;
use std::thread;
fn main() {
// Shared buffer protected by nothing.
let buffer = Arc::new(vec![0u8; 1024]);
let mut handles = vec![];
// Spawn two threads that write to the same index.
for _ in 0..2 {
let buffer = buffer.clone();
handles.push(thread::spawn(move || {
// Writing to the same memory location from multiple threads
// without synchronization is undefined behavior.
// Safe Rust prevents this, but `unsafe` lets you shoot yourself in the foot.
unsafe {
let ptr = buffer.as_ptr();
// Both threads write to index 0 simultaneously.
*ptr = 0xFF;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
}
Run this with cargo miri test or cargo miri run. Miri simulates the threads. It finds an interleaving where both threads write to the same location without synchronization. It reports a data race.
error: Undefined Behavior: data race detected
--> src/main.rs:18:17
|
18 | *ptr = 0xFF;
| ^^^^^^^^^^ this write of size 1 races with a previous write of size 1
The report identifies the conflicting accesses. It shows the stack traces for both threads. You fix the bug by adding synchronization, like a Mutex or AtomicU8, or by restructuring the code to avoid shared mutable state.
Miri explores the concurrency space. It finds the races that hide in production.
Pitfalls and limitations
Miri is powerful, but it has limits. It's slow. Interpreting code is orders of magnitude slower than running native machine code. A test suite that takes two seconds might take two minutes under Miri. Use Miri on a subset of your code. Focus on tests that exercise unsafe blocks, FFI calls, and concurrent code.
Miri simulates a generic operating system. It doesn't support every syscall. You might get "unsupported syscall" errors when your code calls into the OS. You can disable isolation to allow real syscalls, but this reduces Miri's ability to detect some bugs.
# Allow real syscalls if Miri blocks your code.
MIRIFLAGS="-Zmiri-disable-isolation" cargo miri test
Miri doesn't catch logic errors. If your code returns the wrong value, Miri doesn't care. Miri only catches undefined behavior. You still need unit tests to verify correctness.
Miri also has limitations with dynamic linking and external crates. Some crates might use features that Miri doesn't support yet. Check the Miri documentation for the latest supported features.
Convention aside: Use MIRIFLAGS="-Zmiri-ignore-leaks" when testing code that intentionally leaks memory, like caches or long-lived handles. Miri reports leaks by default, which can be noisy for certain patterns.
Miri is slow. Run it on the code that matters. Trust it where it runs.
When to use Miri versus other tools
Choose the right tool for the job. Miri is the most thorough checker, but it's not the only option.
Use Miri when you have unsafe code and need to verify pointer safety and data races. Use Miri when you are building abstractions like Vec or allocators and need to prove memory safety. Use Miri when you suspect a Heisenbug that vanishes under normal execution.
Reach for AddressSanitizer (ASan) when you need faster detection on large codebases or in CI, accepting that it runs on real hardware and might miss some UB. Reach for ThreadSanitizer (TSan) when you need to detect data races in production-like environments, though it has higher overhead than ASan.
Pick fuzzing when you need to find edge cases in input parsing, often combined with Miri for maximum coverage. Pick standard unit tests when you need to verify logic and correctness, not memory safety.
Miri is your safety net for the unsafe parts of your life. Run it regularly. If it passes, you can sleep.