How the Rust compiler works
An overview of rustc's compilation pipeline: lexing, parsing, HIR/MIR lowering, codegen, and linking. Plus common cargo profile options explained.
engineering/dev/lang/rust
14 min read
Mar 16, 2026
Aren't you curious about how your Rust source code turns into a blazingly fast binary? Or about why that process is blazingly slow?
Look no further!
Note
Most of the following information is not specific to Rust and applies to all compiled languages.
A reference for this article.
Compilation steps
The compilation process goes through the following stages:
| Step | Role | Validation checks | Example (conceptual) |
|---|---|---|---|
| 1. Source | Raw source code (.rs files) and external dependencies. |
let mut x = 5; |
|
| 2. Lexer (frontend) |
Performs lexical analysis: strips whitespace/comments and groups raw text characters into a stream of atomic tokens. | Lexical validity: checks for illegal characters, unclosed quotes, or malformed numbers. | [Keyword(Let), Keyword(Mut), Ident("x"), Punct(Eq), Literal(Int(5)), Punct(Semi)] |
| 3. Parser (frontend) |
Turns the token stream into a hierarchical Abstract Syntax Tree (AST). Macro expansion is interleaved here: declarative macros ( macro_rules!) are expanded eagerly during parsing; procedural macros are invoked in a fixed-point loop until no unexpanded macros remain. |
Syntax checking: validates grammar rules (e.g. missing semicolons, mismatched braces, malformed expressions). | Local { pat: Ident("x"), mutability: Mut, init: Lit(Int(5)) } |
| 4. HIR Lowering (frontend) |
Desugars the AST into High-Level Intermediate Representation (HIR). Complex constructs are simplified (e.g. for loops become loop + match). |
Semantic analysis & type checking: name resolution, trait resolution, type inference, and privacy/visibility checks. | Node::Local( pat: Binding(Mut, DefId(x)), ty: i32, init: Expr(5_i32) ) |
| 5. MIR Lowering (middle) |
Lowers HIR into Mid-level IR (MIR). Converts the code into a Control Flow Graph using basic blocks and simple statements. Rust-specific optimizations happen here. |
Borrow checking: validates lifetimes, ownership, drop checking, and catches use-of-uninitialized variables. (this is where cargo check stops) |
bb0: { _1 = const 5_i32; return; } |
| 6. Codegen (backend) |
Converts MIR into the target backend (LLVM / Cranelift) IR. Backend optimizations happen here. Backend translates the IR into machine code (object files: .o), one per codegen unit. |
IR validation: the backend verifier ensures the generated IR is well-formed before optimization. | %x = alloca i32, align 4store i32 5, ptr %x |
| 7. Linking | The linker takes the generated object files and system libraries, combining them into the final binary. | Symbol resolution: checks for missing symbols (undefined references) or duplicate function definitions. | 00000000000052a0 <main>:mov dword ptr [rbp - 4], 5 |
rustc can print intermediate representations
-Zunpretty=expanded— prints the macro-expanded source code.-Zunpretty=hir— prints the HIR output.-Zunpretty=mir— prints the MIR output.--emit=llvm-ir— emits the LLVM IR (written to a.llfile).--emit=asm— emits the assembly output (written to a.sfile).
e.g. rustc +nightly main.rs -Zunpretty=mir
All -Z flags require a nightly toolchain.
rustc AST
rustc's AST is a tree built from the lexer token stream using a recursive-descent parser.
It leverages 5 core node kinds to assemble composite structures. With these, one can represent any valid Rust code:
- Items — top-level declarations:
fn,struct,enum,impl,trait,use,mod, etc. - Types — e.g. primitives,
&mut String, orOption<T>. - Expressions — constructs that evaluate to a value. e.g.
x + y. - Patterns — destructuring of values, as used in
if let,match, function arguments, etc. - Statements — instructions that do something but do not evaluate to a value. e.g.
let mut x = 5;.
A basic example is:
let pat: ty = {
stmt;
expr
};
If you've worked with Rust macros (procedural or declarative), you've definitely encountered these terms.
Query-based compilation frontend
rustc does not execute the compilation pipeline as a simple top-to-bottom pass. Every piece of compiler work (e.g. type-checking a function, computing the MIR for an item, resolving a trait) is expressed as a query with a key and a result that is memoized. If two different parts of the compiler need the same answer, it is computed only once thanks to this cached query graph.
This is what makes incremental=true work:
- rustc serializes the query result cache in
target/. - On next build, only query that had their input changed are re-executed and transitive codegen-units are re-emitted. Other queries are still cached so rustc will skip type-checking, borrow-checking, and codegen for that item entirely, and re-use the codegen-unit as-is.
Interesting consequences:
- Granularity is per-item, not per-file. Changing one function only invalidates queries that transitively depend on it.
- The higher
codegen-units, the fewer object files have to be re-emitted when a query is invalidated. cargo checkexploits the same system but stops after the borrow-checker queries — it never fires the codegen queries, which is why it is so much faster.
Cargo options
Options Cargo exposes to alter the compilation process:
| Option=default Possible values |
What it does | Benefit | Trade-off |
|---|---|---|---|
opt-level=30 (none) / 1 / 2 / 3 (heavy) "s" / "z" (optimize for binary size) |
Instructs the frontend and backend to apply optimization passes. | + runtime performance. XXL | - compile time. XXL |
incremental=truetrue / false |
Caches intermediate artifacts so unchanged parts are reused across builds. | + compilation time. XXL | - artifacts size (target/ folder). M |
codegen-units=161 to 256+ dev default: 256 release default: 16 |
Splits a crate into n chunks processed in parallel by the backend.Reduces cross-unit optimization opportunities. |
+ compile time. S - binary size. L - artifacts size. M |
- execution speed. XS |
debug=false0 / false / "none""line-tables-only" (file:line only)1 / "limited" (all but variable names)2 / true / "full" |
Instructs the compiler to emit (or omit) debugging symbols and source mappings. | + compile time. XL + artifact size. XL + binary size. XXL |
- observability. XXL |
split-debuginfo="unpacked""off" (embedded in binary)"packed" (single separate file)"unpacked" (separate files) |
Controls where debug information is stored: embedded in the binary or split into separate files. | + binary size. XXL + linking speed. XL |
- disk clutter |
strip="symbols"false / "none""debuginfo"true / "symbols" |
Strips symbols and/or debug info from the final binary after linking. | + final binary size. XXL | - crash reporting. XL |
panic="abort""unwind""abort" |
Immediately aborts the process on panic instead of unwinding the call stack. Panics cannot be caught. |
+ binary size. M + execution speed. S |
- panic handling. L (depends on requirements) |
codegen-backend="cranelift"llvmcranelift(nightly only) |
Swaps out LLVM for the Cranelift code generator. | + fresh compile time. L + artifact size. XL |
- execution speed. XXL |
lto="thin"false"thin""fat" |
Performs Link-Time Optimization across crate boundaries. Largely wasted when paired with high codegen-units, incremental, or a fast linker. |
+ execution speed. XL + binary size. XL |
- compilation time (link). XXL |
linker="mold"ld / lld / rust-lld (nightly) / mold / wild |
Replaces the system linker with a high-parallelism alternative. | + link time (incremental). XXL | - platform and option restrictions - requires external installation |
linker="clang"cc / gcc / clang |
Uses a C compiler front-end to drive the linking process. Required by some fast linkers (e.g. wild). |
+ compatibility with the Rust toolchain + enables fast linker backends |
- may increase total build time vs. cc alone |
-Zthreads=N1 to N (nightly only) |
Enables multi-threading in the rustc frontend (parsing, analysis, codegen). | + cold compile time. XXL | |
-Zshare-generics=yy / n |
Shares monomorphized generic code across codegen units within the same crate, reducing duplicate work. | + compilation time. L + binary size. L |
- minor runtime edge-cases |
-Ctarget-cpu=nativegeneric / native / specific CPU name |
Optimizes the binary for the host machine's specific CPU features. | + execution speed. S | - portability. XXL (binary may crash on older CPUs) |
sccache (external tool) |
Caches compiled dependency artifacts in a shared repository across projects. Incompatible with incremental=true. |
+ cold compilation time across projects. XXL | - requires setup. XS - space management: cargo clean won't touch it.Remember to flush after rustup update. XL- cache misses depending on crate feature flags |
For in-depth compiler optimization, see this article.
See the Cargo book — profiles for official definitions and default values for the dev / release profiles.
Thanks for reading. I hope it has sparked some new thoughts or insights! Questions, remarks, or caught an inconsistency? My inbox is open, please reach out.