What Is MIR (Mid-Level IR) in Rust?

MIR is the Rust compiler's internal intermediate representation used for optimization and safety analysis before generating machine code.

The compiler sees a flowchart, not your braces

You write a function that borrows a vector, pushes a new element, and then tries to use the old borrow. The compiler rejects the code with a borrow checker error. You stare at the braces and think the borrow should have ended before the push. The compiler disagrees.

The disagreement happens because the compiler does not check borrows against your braces. It checks borrows against a control flow graph that tracks exactly when values are read and written. That graph is MIR.

MIR stands for Mid-Level Intermediate Representation. It is an internal, platform-independent format the compiler uses to analyze and optimize your code. You never write MIR. You never edit MIR. But MIR is where the borrow checker runs, where Non-Lexical Lifetimes work, and where tools like Miri detect undefined behavior in unsafe code. Understanding MIR explains why the compiler accepts some code and rejects other code that looks identical to you.

What MIR sits between

Rust compilation happens in stages. Your source code becomes an Abstract Syntax Tree, then High-Level IR, then MIR, then LLVM IR, and finally machine code.

HIR preserves the structure of your code. It looks like your source, just typed and desugared. HIR is a tree. Trees have scopes. In HIR, a variable lives until the end of the block where it is declared.

LLVM IR is close to the metal. It knows about registers, instruction scheduling, and CPU architectures. LLVM IR is where loop unrolling and vectorization happen.

MIR sits in the middle. It is low-level enough to expose control flow and data dependencies, but high-level enough to preserve Rust semantics like borrows, lifetimes, and drop glue. MIR is a graph, not a tree. Nodes are basic blocks. Edges are jumps. Statements inside blocks manipulate temporaries. Terminators at the end of blocks decide where execution goes next.

The borrow checker runs on MIR. Because MIR is a graph, the checker can see that a borrow ends when the last use of the reference happens, even if the brace is still open. This is how Non-Lexical Lifetimes work. MIR enables the compiler to be smarter than lexical scopes allow.

MIR is the compiler's internal language for reasoning about Rust programs. You do not speak MIR, but the shape of MIR determines what the compiler allows.

Inside a MIR basic block

MIR output looks like assembly written by a mathematician. Every variable becomes a temporary slot. Every expression breaks into small steps. Control flow splits into blocks.

Run this command to see MIR for a simple function:

rustc -Z unpretty=mir your_file.rs

The flag -Z unpretty=mir tells rustc to print MIR instead of compiling. This is a developer tool for inspecting compiler internals. The output format is unstable and may change between Rust versions.

Here is a minimal example:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

The MIR for this function contains a single basic block:

// MIR snippet for add
bb0: {
    StorageLive(_1); // _1 holds the value of parameter a
    StorageLive(_2); // _2 holds the value of parameter b
    StorageLive(_3); // _3 is the return value slot
    _3 = _1 + _2;    // Binary addition of temporaries
    _0 = _3;         // Move result into return slot _0
    StorageDead(_3); // Clean up temporary
    StorageDead(_2); // Clean up parameter b
    StorageDead(_1); // Clean up parameter a
    return;          // Terminator: exit function
}

The variables a and b are gone. MIR uses numbered temporaries like _1 and _2. The expression a + b becomes a Binary statement that reads two slots and writes a result. StorageLive and StorageDead mark when memory for a temporary is allocated and freed. The return terminator ends the block.

MIR flattens everything. There are no nested scopes. There are no complex expressions. Just a sequence of simple statements and jumps. This flat structure makes analysis easier. The compiler can walk the graph and track data flow without worrying about syntax trees.

MIR strips away names and structure. Everything becomes slots and jumps. The compiler trades readability for analyzability.

Why MIR enables Non-Lexical Lifetimes

Before Rust 2018, borrows lasted until the end of the lexical scope. If you borrowed a value inside a block, the borrow stayed active until the closing brace, even if you never used the reference again. This forced you to write artificial blocks to shorten borrows.

// Old style: artificial block needed
let mut v = vec![1, 2, 3];
{
    let x = &v[0];
    println!("{}", x);
} // Borrow of v ends here
v.push(4); // OK

Non-Lexical Lifetimes changed this. Now the borrow ends at the last use of the reference. The artificial block is no longer needed.

// NLL: borrow ends at last use
let mut v = vec![1, 2, 3];
let x = &v[0];
println!("{}", x); // Last use of x
v.push(4); // OK, borrow of v ended

This works because the borrow checker runs on MIR. MIR has a control flow graph. The checker walks the graph and sees that x is never read after the println. It marks the borrow as dead at that point. The brace is irrelevant.

MIR exposes the data flow. The borrow checker trusts the flow, not the braces. This is why NLL feels magical. It is not magic. It is graph analysis on MIR.

If you have a borrow checker error that seems impossible, look at the control flow. The compiler is following the graph in MIR. Your mental model might be following the braces. The mismatch causes the error.

Braces define scope in HIR. Data flow defines scope in MIR. The borrow checker trusts the flow.

Miri: Running MIR to catch undefined behavior

MIR is not just for the borrow checker. It is also the target for Miri, an interpreter that executes MIR to detect undefined behavior.

The Rust compiler checks rules. It ensures you do not borrow mutably while an immutable borrow exists. It ensures types match. But the compiler does not check all forms of undefined behavior. It does not catch out-of-bounds access on raw pointers. It does not catch use-after-free in unsafe code. It does not catch data races in single-threaded unsafe code.

Miri fills this gap. Miri interprets MIR with extra checks. It simulates execution and halts when it finds UB.

Install Miri with rustup:

rustup component add miri

Run Miri on your crate:

cargo miri run

Miri compiles your code to MIR, then executes the MIR in a virtual machine. The virtual machine tracks memory, pointers, and thread state. It catches bugs that the compiler ignores.

Here is an example of unsafe code that compiles but has UB:

fn main() {
    let x = 42;
    let ptr = &x as *const i32;
    // x is dropped here, but ptr still points to its memory
    // Dereferencing ptr is undefined behavior
    let val = unsafe { *ptr };
    println!("{}", val);
}

The compiler accepts this code. It does not know that x is dropped. The unsafe block silences the borrow checker. But Miri catches the problem:

error: Undefined Behavior: pointer to `i32` is dangling
  --> src/main.rs:5:20
   |
5  |     let val = unsafe { *ptr };
   |                    ^^^^^^^ pointer to `i32` is dangling
   |
   = help: this indicates a bug in the program: it performed an invalid operation, and caused undefined behavior

Miri reports the exact line and explains the issue. It saves you from subtle bugs that only appear in production or under specific conditions.

Use Miri whenever you write unsafe code. It is the standard tool for verifying safety invariants. The community treats Miri as a second compiler that checks for UB.

The compiler checks rules. Miri checks reality. Run Miri on unsafe code.

Pitfalls and conventions

MIR is an internal representation. The output format is not stable. Do not write tools that parse MIR output. The format may change in any Rust release. Use MIR only for debugging or learning.

When you use rustc -Z unpretty=mir, the output includes metadata about spans, types, and debug info. This metadata is verbose. Focus on the basic blocks, statements, and terminators. The structure tells you how the compiler sees your code.

A common convention is to use cargo miri for testing unsafe code. Add a test that exercises the unsafe path, then run cargo miri test. This catches UB early in development. Some projects run Miri in CI to ensure unsafe code stays safe.

Another convention is to keep unsafe blocks small. MIR makes it clear that unsafe code bypasses checks. The smaller the unsafe block, the easier it is to verify the safety proof. The community calls this the minimum unsafe surface rule. MIR helps you see where checks are bypassed.

MIR is a diagnostic tool, not a stable API. Treat MIR output as a log, not a contract.

Treat MIR output as a diagnostic log, not a stable API.

When to look under the hood

MIR is useful for specific problems. You do not need MIR for daily Rust development. Use the right tool for the job.

Use rustc -Z unpretty=mir when you need to debug a borrow checker error that defies your mental model of scopes. The MIR output shows the control flow graph and reveals where the compiler thinks borrows live and die.

Use cargo miri when you are writing unsafe code and need to verify there is no undefined behavior. Miri catches out-of-bounds access, use-after-free, and data races that the compiler ignores.

Use rustc -Z unpretty=hir when you want to see macro expansion or type inference results without the complexity of control flow. HIR is closer to your source code and easier to read.

Use standard cargo build when you are ready to ship. MIR is an internal representation. The compiler manages it. You do not need to touch it for daily work.

MIR is a window into the compiler's brain. Peek inside when you are stuck, then close the window and write Rust.

Where to go next