How rustc Checks Your Code: MIR Borrow Checking, Lifetime Inference, Monomorphization and Trait Coherence

Key takeaways

Beyond syntax: how rustc validates ownership and borrows on MIR, how lifetime regions are solved, how generics and trait objects lower to machine code, and which production habits follow from those rules.

This is not a syntax cheat sheet. It explains how the Rust compiler reasons about memory and traits, and how those rules show up when you debug real code: when the borrow checker rejects something that looks fine, when a lifetime annotation is actually needed, why dyn calls appear in a flamegraph, and why a harmless-looking change to a public type breaks downstream crates. For the basics, start with the intro series; this article assumes you have written some Rust already.


Ownership and the borrow checker

The rules you see

Every value has exactly one owner, and the owner drops the value when it goes out of scope. At any point, a given place (a variable, field, or element path) can have either any number of shared borrows &T or a single mutable borrow &mut T, but not both. These two rules are what let Rust rule out use-after-free and data races without a garbage collector. The subtle part is what “at any point” means, and that is where the compiler’s internals matter.

NLL: a borrow ends at its last use

Early Rust tied a reference’s validity to the lexical block it lived in, so a borrow stayed active until the closing brace even if the reference was never used again. Non-lexical lifetimes (NLL), stable since the 2018 edition, changed this: a borrow is live only from its creation to its last use.

fn nll_example(v: &mut Vec<i32>) {
    let r = &v[0];
    println!("{}", *r); // last use of r: the shared borrow ends here
    v.push(1);          // OK: no shared borrow is live anymore
}

Move the println! below the push and the program is rejected again, correctly: push may reallocate the buffer, and r would then point into freed memory. That is the pattern to look for when an error survives NLL. It is almost always a reference that is still used after something that could invalidate it.

Why it runs on MIR

rustc lowers your code to MIR, a control-flow graph of basic blocks where every drop, move, and temporary is explicit. On that graph, the question “is this borrow still live on this edge?” becomes an ordinary data-flow problem, and the checker reports an error when a conflicting access happens while a borrow is live. Working on MIR is also why error messages sometimes mention temporaries or drops you never wrote: they exist in MIR even if they are invisible in the source.

Two details explain many surprising results:

  • Two-phase borrows. In v.push(v.len()), the method call needs &mut v, but the argument reads v first. The compiler reserves the mutable borrow, evaluates the arguments with shared access, and only then activates it. Without this, that idiom would not compile.
  • Drops count as uses. If a value with a Drop impl holds a reference, its destructor runs at the end of the scope and counts as a use of that reference. A borrow you thought ended early can therefore extend to the closing brace. Types without drop glue, such as plain references, do not have this effect.

Polonius

The current analysis is fast but rejects some programs that are actually safe. The classic case is a function that conditionally returns a borrow from a map and otherwise inserts into the same map. Polonius is a reformulation of borrow checking that accepts such code. It is still in development and not enabled on stable, so do not design APIs around it. When you hit this limitation today, restructure the code (look up first, then borrow again), or use APIs like HashMap::entry that were designed around it.

unsafe and the aliasing model

unsafe means you take over proof obligations the compiler normally checks: that references are valid, initialized, and not aliased in ways the type system promises they are not. Stacked Borrows and Tree Borrows are models that define precisely when mixing raw pointers and references is undefined behavior. You do not need to memorize them, but you should run Miri (cargo +nightly miri test) on any module that casts between references and raw pointers, transmutes lifetimes, or implements its own allocator or collection. Miri executes the code under these models and reports violations that tests on real hardware usually miss.


Lifetime inference

Lifetimes are not runtime values

A lifetime parameter 'a is a generic parameter over regions of the program. It is used during type and borrow checking and erased before code generation, so it never costs anything at runtime and never causes extra monomorphized copies.

Regions and constraints

Inside a function body, the compiler gives every reference a region variable, collects constraints such as “this region must outlive that one” from assignments, calls, and returns, and then finds the smallest regions that satisfy them all. An error means no assignment exists, and the message names the constraint that could not be satisfied.

fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Function signatures are not inferred; they are the contract the body is checked against. Here both inputs and the output share 'a, so the caller can pass references with different lifetimes and the compiler picks an 'a no longer than the shorter one. The result cannot be used after either input’s borrow ends, even if it happened to be the longer-lived one at runtime, because the signature does not say which input it came from.

Elision rules

You rarely write lifetimes in signatures because three elision rules cover most cases. Each elided input reference gets its own lifetime. If there is exactly one input lifetime, it is assigned to all elided output lifetimes. If one of the inputs is &self or &mut self, its lifetime is assigned to the outputs. When none of these apply, for example a free function with two reference inputs returning a reference, you must annotate, as in pick above. Annotating also helps when the elided choice is wrong for your intent: a method on &self that returns a reference derived from an argument rather than from self needs an explicit lifetime, otherwise callers get errors claiming self is still borrowed.

Variance

Variance describes when a type with a longer lifetime can be used where a shorter one is expected. 'static outlives every 'a, so &'static str can be used as &'a str; that makes &'a T covariant in both 'a and T. &'a mut T is covariant in 'a but invariant in T, and the reason is concrete:

fn overwrite<'a>(slot: &mut &'a str, s: &'a str) {
    *slot = s;
}

fn main() {
    let mut name: &'static str = "static";
    {
        let local = String::from("short-lived");
        // overwrite(&mut name, &local); // rejected
    }
    println!("{name}");
}

If &mut &'static str could be treated as &mut &'a str for a shorter 'a, overwrite would store a reference to local in a variable the caller still believes is 'static, and the println! would read freed memory. Invariance forbids that coercion. The same reasoning makes Cell<T> and other interior-mutability types invariant in T. When an error says a lifetime “must outlive 'static” and you never wrote 'static, an invariant position is often the reason the compiler could not shorten it.

Higher-ranked trait bounds

A higher-ranked bound for<'a> says the bound must hold for every lifetime, not for one lifetime chosen by the caller. It is what you need when a closure will be called with references that are created inside your function, which the caller cannot name:

fn apply<F>(f: F, s: &str)
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    let owned = s.to_uppercase();
    let _ = f(&owned); // a borrow that only exists inside apply
}

With the Fn(&str) -> &str sugar, elision already produces exactly this bound, so you mostly write for<'a> explicitly on traits that do not have the parenthesized sugar, for example where for<'a> &'a T: IntoIterator.


Zero-cost abstractions

“Zero-cost” means you do not pay for features you do not use, and an abstraction you do use compiles to roughly what you would have written by hand, provided the optimizer can inline it. That condition is where the details matter.

Monomorphization

For fn foo<T: Trait>(t: T), the compiler generates a separate copy of foo for every concrete T used, much like C++ templates. Each copy calls the trait methods directly, so they can be inlined and optimized for that type. The price is compile time and binary size. A generic function with a large body instantiated for many types multiplies the work LLVM has to do. A common mitigation is to keep the generic part thin and forward to a non-generic inner function, which is what std does for functions like std::fs::read that take impl AsRef<Path>.

Iterator chains

pub fn sum_squares(v: &[i32]) -> i32 {
    v.iter().map(|x| x * x).sum()
}

After inlining, this chain becomes a single loop equivalent to a hand-written for, and slice iterators even let the compiler drop bounds checks that indexed loops would need. The guarantee breaks when inlining fails: a closure behind a Box<dyn Fn>, an adapter crossing a trait object, or a crate boundary for a non-generic function not marked #[inline]. If a profile shows iterator adapter frames in a hot path, that is the signal to look for one of these. Note also that x * x panics on overflow in debug builds and wraps in release builds; use checked_mul or wrapping_mul when the distinction matters.

dyn Trait

A &dyn Trait or Box<dyn Trait> is a wide pointer: the data address plus a pointer to a vtable for the concrete type. Calls go through the vtable and usually cannot be inlined. Use dyn where the set of types is open or only known at runtime, such as plugins and heterogeneous collections of user-defined types; when the set is closed and small, an enum is often simpler and faster. Not every trait can be used as dyn. A trait with generic methods, or methods returning Self, is not dyn-compatible (formerly called object-safe) unless those methods are excluded with where Self: Sized.

async fn

An async fn compiles to a state machine: a value that stores the local variables live across each .await and a poll method that resumes from the last suspension point. The state machine itself is cheap, but its size is the size of the largest set of locals live across any await, so holding a large buffer across an .await makes every future of that type large. Runtime costs live in the executor: scheduling, wakers, and what happens when a task blocks. In Tokio, blocking calls such as synchronous file I/O or heavy computation belong in spawn_blocking, otherwise they stall a worker thread and every task scheduled on it. Dropping a future cancels it at its current .await, so any code after that point never runs; design cleanup with that in mind.


Trait resolution

Coherence and the orphan rule

Coherence means that for any trait and type there is at most one applicable impl, across the whole dependency graph. The orphan rule enforces this locally: you may write impl Trait for Type only if the trait or the type is defined in your crate (with additional rules for generic parameters). Without it, two unrelated dependencies could both implement Display for Vec<u8>, and adding a crate could break compilation elsewhere. The usual workaround for a foreign trait on a foreign type is a newtype wrapper such as struct Bytes(Vec<u8>).

Associated types versus generic parameters

Iterator::Item is an associated type because each iterator type yields exactly one item type, and fixing it per impl means callers never have to name it and inference has one answer. A generic trait like From<T> is the opposite case: one type can implement From<u8>, From<String>, and others side by side. If you find yourself adding a type parameter to a trait but every type implements it only once, it should probably be an associated type.

Specialization

Specialization, letting a more specific impl override a blanket one, is still unstable and has known soundness problems in its general form. The standard library uses a restricted internal version. Application and library crates should not depend on it; use separate traits or explicit impls instead.

Deref and method resolution

Method calls auto-reference and auto-dereference the receiver, following Deref impls until a method matches. This is what lets you call str methods on a String or a Box<String>, but it also means adding a method to an inner type can change which method an outer call resolves to. For that reason Deref is meant for smart pointers, not for emulating inheritance, and public APIs are clearer with explicit accessor methods.


Production habits that follow from these rules

Error layering

Libraries usually define error enums, often with thiserror, so callers can match on the variants they can handle. Binaries and service entry points usually use anyhow::Result and attach context with .context("loading config"), because at that level the error is mostly logged, not matched.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("missing env {0}")]
    MissingEnv(String),
    #[error("invalid port")]
    InvalidPort,
}

Observability

Prefer tracing spans with structured fields such as request id and tenant over ad hoc println!. Spans follow a request across .await points, where thread-local logging context would be lost because a task can resume on a different worker thread, and they export to OpenTelemetry through tracing-opentelemetry.

CI checks

cargo fmt --check
cargo clippy --all-targets -- -D warnings   # -D warnings goes after --, to clippy itself
cargo test
cargo audit                                  # known vulnerabilities in dependencies
cargo deny check                             # licenses, duplicate versions, banned crates
cargo +nightly miri test                     # for crates with unsafe code

Lint flags such as -D warnings belong after the --, where they are passed to clippy itself. Writing cargo clippy -D warnings puts them in the position for cargo’s own options, a common slip in CI files that copy the command from memory.

Send, Sync, and shared state

Rc is neither Send nor Sync because its reference count is not atomic, which is why the compiler rejects moving it into tokio::spawn or std::thread::spawn. Use Arc for shared ownership across threads, combined with Mutex or RwLock when the data is mutated. RwLock helps only when reads dominate and are not trivially short, and std::sync::RwLock leaves the reader/writer priority policy to the operating system, so writer starvation is possible on some platforms. In async code, a std::sync::Mutex guard held across an .await makes the future non-Send, which is the compiler pointing at a real problem: the lock could be held while the task is suspended.

SemVer for public types

Adding a variant to a public enum breaks downstream code that matches it exhaustively, and adding a public field to a struct breaks code that constructs it with a struct literal. Marking such types #[non_exhaustive] from the first release keeps those additions non-breaking. Adding a blanket impl, or adding a method to a trait without a default, is also breaking. cargo-semver-checks can detect many of these cases in CI before release.


References