How to Reduce Rust Compile Times

Enable incremental compilation in Cargo.toml to cache build artifacts and skip recompiling unchanged code.

The wait kills momentum

You change a single character in a helper function. You hit save. You watch the terminal progress bar crawl. Ten seconds. Twenty. Your brain drifts to lunch. The build finishes. You fix the typo. You wait again. This loop destroys flow. Rust compiles slowly because it does heavy lifting: monomorphizing generics, running aggressive optimizations, and verifying memory safety at every step. You can tune this process. You don't have to accept the wait.

The factory analogy

Think of compilation like a factory assembling a complex engine. The compiler takes your source code, checks the blueprints, generates machine instructions, and links everything into a binary. If you change a bolt specification, a smart factory doesn't melt down the entire engine or rebuild the assembly robots. It just swaps the bolt.

Rust's default behavior sometimes feels like rebuilding the robots. Incremental compilation teaches the compiler to remember the intermediate steps so it only repeats the work that changed. Profile settings control how much the factory optimizes each part. The linker is the final assembly line that combines all the pieces. Tuning these stages reduces the time between your keystrokes and a working binary.

Incremental compilation

Incremental compilation caches intermediate results on disk. When you modify code, the compiler loads the cache and only recompiles the affected modules. This trades disk space for build time. The cache lives in the target directory and can grow large, often several gigabytes for a medium project.

Enable incremental compilation in your Cargo.toml. The dev profile enables it by default in modern Cargo, but explicit configuration ensures it stays on even if defaults change.

[profile.dev]
incremental = true

[profile.release]
incremental = true

The dev profile gets incremental compilation. The release profile usually doesn't need it because you build release binaries less often. Setting incremental = true in release can help if you iterate on release builds, but it increases disk usage and can sometimes slow down the final link step.

Incremental compilation works by caching MIR (Mid-level Intermediate Representation). MIR is a simplified version of your code that the compiler uses for optimization and code generation. When you change a function, the compiler invalidates the MIR for that function and any code that depends on it. It then regenerates only the affected MIR and proceeds to code generation.

The cache is not perfect. Some changes invalidate more than expected. Complex macro expansions or heavy generic usage can cause wider invalidation. The compiler is conservative to guarantee correctness. If the cache thinks something changed, it recompiles it.

Incremental compilation is the first line of defense. Turn it on and forget it.

Profile tuning

Cargo profiles control optimization levels, debug info, and parallelism. The dev profile prioritizes fast compilation and debugging. The release profile prioritizes runtime performance. You can tweak dev to compile even faster by relaxing requirements.

Code generation units

The codegen-units setting controls how the compiler splits work among CPU cores. Each unit is compiled independently and linked together. More units mean more parallelism and faster compilation. Fewer units mean the compiler can optimize across more code, resulting in faster runtime performance and smaller binaries.

[profile.dev]
codegen-units = 16

The default codegen-units is usually 16 or based on your CPU core count. Increasing this number speeds up compilation but reduces optimization quality. Decreasing it improves optimization but slows compilation. The community convention is to leave codegen-units at default unless you measure a problem. Premature tuning leads to weird runtime performance drops.

If you are building a large project and compile time is unbearable, try increasing codegen-units to 32 or 64. You will see faster builds. Your binary will be slightly slower and larger. This trade-off is acceptable for development.

Optimization level

The opt-level setting controls how much the compiler optimizes your code. Level 0 disables most optimizations. Level 1 enables basic optimizations. Level 2 enables aggressive optimizations. Level 3 enables even more aggressive optimizations, including auto-vectorization.

[profile.dev]
opt-level = 0

Setting opt-level = 0 skips optimization passes. This dramatically reduces compile time. The resulting binary runs slower. For development, you usually don't care about runtime speed. You care about feedback loops. Level 0 is the default for dev. Ensure it stays that way.

Debug info

Debug info helps debuggers map machine code back to source lines. Generating debug info takes time and disk space. The debug setting controls this.

[profile.dev]
debug = "line-tables-only"

The default debug = 2 generates full debug info, including variable names and types. This is useful for debugging but slows compilation. Setting debug = "line-tables-only" generates only line number mappings. This is enough for stack traces and basic debugging. It compiles faster and uses less disk space. Use this when you don't need to inspect variables in a debugger.

Profile tuning gives you knobs to turn. Adjust them based on your needs. Don't optimize blindly.

The linker bottleneck

The linker combines object files into a final binary. It resolves symbols, applies relocations, and strips unused code. The linker is often single-threaded and can become a bottleneck, especially for large projects with many dependencies.

Rust uses cc to invoke the system linker. The default linker is usually ld or gold. These linkers are reliable but slow. Alternative linkers like lld and mold are significantly faster.

Install lld or mold on your system. Configure Cargo to use it via .cargo/config.toml.

[build]
rustflags = ["-C", "linker=lld"]

The rustflags array passes flags to the compiler. The -C linker=lld flag tells the compiler to use lld as the linker. If lld is not installed, the build fails with E0463 (can't find crate for std) or a linker error. Install the linker first.

mold is often faster than lld for large projects. Use linker=mold if you have mold installed. The speedup can be dramatic. A build that takes 30 seconds might drop to 10 seconds.

A faster linker shaves seconds off the end of every build. Install lld or mold.

Code structure and bloat

Your code structure affects compile time. Rust's generics are powerful but expensive. Generics use monomorphization. The compiler generates a separate copy of the code for every type used. If you have a Vec<T> and use it with String, i32, and MyStruct, the compiler generates three copies of Vec code.

Monomorphization ensures zero-cost abstractions. The generated code is as fast as hand-written code for each type. It also means compile time grows with the number of types. Heavy generic usage can explode compile times.

Trait objects use dynamic dispatch. The compiler generates one copy of the code and uses a vtable to call the correct method at runtime. This reduces compile time but adds a small runtime overhead.

// Generic: fast runtime, slower compile
fn process<T: Trait>(item: T) {
    item.do_work();
}

// Trait object: slightly slower runtime, faster compile
fn process_dyn(item: &dyn Trait) {
    item.do_work();
}

Use generics when performance matters and the number of types is small. Use trait objects when compile time is the bottleneck and you have many types. The runtime cost of dynamic dispatch is usually negligible compared to I/O or network latency.

Generic bloat is invisible until it hurts. If compile times spike after adding a new type, check for monomorphization. Switch to dyn where appropriate.

Monomorphization is the price of zero-cost abstractions. Pay it wisely.

Pitfalls and errors

Tuning compile times introduces trade-offs. Incremental compilation uses disk space and memory. The cache can grow large. If you run out of disk space, builds fail. If you run out of RAM, the system swaps and builds slow down.

The incremental cache can become corrupted. This happens rarely, usually after a Rust version upgrade or a manual edit to the target directory. Symptoms include spurious recompilation or build errors that disappear after cleaning.

cargo clean

Running cargo clean removes the target directory and forces a full rebuild. This fixes cache corruption. It also resets incremental state. Use this when builds behave strangely.

Linker configuration errors are common when switching linkers. If you set linker=lld but lld is not in your PATH, the build fails. The error message mentions the linker not being found. Ensure the linker is installed and accessible.

High codegen-units can cause runtime performance regressions. The compiler optimizes less across units. Critical loops might run slower. Profile your release builds if you tweak codegen-units. Don't assume faster compile times mean acceptable runtime performance.

If the build behaves strangely, clean the cache. The compiler is smart, but the cache can lie.

Decision matrix

Use incremental compilation when you iterate frequently on the same codebase and have sufficient disk space. Use codegen-units higher than default when compile time is the bottleneck and you don't need maximum runtime performance for development builds. Use opt-level = 0 when you only need to check types and logic, not runtime speed. Use lld or mold when the linker phase dominates your build time and you can install alternative linkers. Use dyn trait objects when generic monomorphization causes excessive compile times and you have many concrete types. Use cargo check when you only need to verify syntax and types without generating the binary.

Where to go next