What Is Monomorphization and How Does It Affect Performance?

Monomorphization is the compiler technique that creates specialized code for each generic type usage, ensuring zero-runtime overhead and optimal performance.

When one function isn't enough

You write a function to find the maximum value in a slice. You make it generic so it works for integers, floats, and custom structs. You worry about the overhead of generics. You run a benchmark. The generic version is faster than the hand-written i32 version.

The compiler didn't just pass a blob of bytes around. It specialized the code for every type you touched. The generic function vanished, replaced by concrete machine code tailored to each type. This process is monomorphization. It is the engine behind Rust's "zero-cost abstractions." You write code once. The compiler generates optimized code many times.

The blueprint that builds itself

Rust's generics are like a master blueprint for a house. The architect draws one set of plans. The builder uses those plans to construct a wooden house, a brick house, and a steel house. Each house is built for its material. The wooden house uses nails and joists. The steel house uses welds and girders. The blueprint isn't the house. The blueprint is the instruction to build specific houses.

Monomorphization turns the generic "poly" form into a single "mono" form. When you define fn largest<T>, you provide the blueprint. When you call largest(&ints), the compiler builds the i32 house. When you call largest(&chars), it builds the char house. The result is a binary containing distinct functions for each type. There is no runtime cost. There is no vtable lookup. The CPU executes instructions designed for the exact type.

Minimal example

Here is a generic function that finds the largest element in a slice. The compiler generates a separate function for every type you use.

/// Finds the largest element in a slice by comparing items.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    // Start with the first element as the current winner.
    let mut largest = &list[0];
    
    // Iterate through every item in the slice.
    for item in list {
        // Compare current item with the winner.
        // The compiler substitutes T with the concrete type here.
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let ints = vec![34, 50, 25];
    let chars = vec!['a', 'z', 'm'];
    
    // The compiler generates largest_i32 for this call.
    let _max_int = largest(&ints);
    
    // The compiler generates largest_char for this call.
    let _max_char = largest(&chars);
}

The function largest appears once in your source code. The binary contains two functions. One uses integer comparison instructions. The other uses character comparison instructions. The code is identical to what you would write if you duplicated the function by hand.

What the compiler actually does

The compiler runs a phase called instantiation. It scans your code for every use of a generic function or struct. It builds a map of type substitutions. For largest(&ints), the map is T -> i32. For largest(&chars), the map is T -> char.

The compiler clones the intermediate representation of the generic function. It applies the substitution map. It replaces every T with the concrete type. It then runs the optimizer on the specialized code. Because the optimizer knows the exact type, it can perform aggressive optimizations. It can unroll loops. It can use SIMD instructions. It can eliminate bounds checks.

This is why generics are fast. The optimizer sees the concrete types. It makes decisions based on real data sizes and layouts. If you used dynamic dispatch instead, the optimizer would see a trait object. It would have to assume the worst case. Monomorphization gives the optimizer the information it needs to generate peak performance code.

Realistic example: Drop and memory

Monomorphization affects more than just function bodies. It affects structs, methods, and drop logic. Consider a generic wrapper.

/// A simple wrapper that holds a value of any type.
struct Wrapper<T> {
    value: T,
}

impl<T> Wrapper<T> {
    /// Creates a new wrapper.
    fn new(value: T) -> Self {
        Wrapper { value }
    }
}

fn main() {
    // Wrapper<i32> has the size of an i32.
    // The compiler generates a version of Wrapper with no drop glue.
    let int_wrapper = Wrapper::new(42);
    
    // Wrapper<String> has the size of a String (pointer, length, capacity).
    // The compiler generates a version of Wrapper that calls String::drop.
    let string_wrapper = Wrapper::new("hello".to_string());
    
    // When string_wrapper goes out of scope, the monomorphized drop code
    // frees the heap memory for the string.
    // When int_wrapper goes out of scope, nothing happens.
}

The Wrapper struct monomorphizes differently for i32 and String. The i32 version has no cleanup code. The String version includes logic to free heap memory. The compiler generates the correct drop behavior for each type. This is how Rust guarantees memory safety without a garbage collector. The cleanup code is baked into the type-specific version of the struct.

Monomorphization versus dynamic dispatch

Rust offers two ways to handle polymorphism. Monomorphization is compile-time polymorphism. Dynamic dispatch is runtime polymorphism. Understanding the difference is essential for choosing the right tool.

With monomorphization, the compiler generates code for each type. The function calls are direct. The CPU jumps to the exact address of the specialized function. With dynamic dispatch, you use a trait object like &dyn Trait. The compiler generates one function. The function call goes through a vtable. The CPU looks up the function pointer at runtime.

Dynamic dispatch allows you to store heterogeneous types in a collection. You can put a Cat and a Dog in a Vec<&dyn Animal>. Monomorphization requires homogeneous types. You can have a Vec<Cat> or a Vec<Dog>, but not both in the same vector.

Monomorphization gives you speed and size efficiency for known types. Dynamic dispatch gives you flexibility for unknown types. The trade-off is performance versus heterogeneity.

Pitfalls and compiler errors

Monomorphization has costs. The primary cost is code bloat. If you use a generic function with 1,000 different types, the compiler generates 1,000 copies of the function. Your binary size grows. Your compile time increases.

The compiler rejects code that cannot be monomorphized. If you forget a trait bound, the compiler tries to generate code and fails.

fn print_value<T>(t: T) {
    // The compiler tries to monomorphize this for every T.
    // It needs Display to format the value.
    println!("{}", t);
}

fn main() {
    // This fails because i32 does not implement Display in this context?
    // Actually i32 does implement Display, but T does not have the bound.
    print_value(42);
}

The compiler rejects this with E0277 (trait bound not satisfied). The error message says the trait Display is not implemented for T. The compiler cannot generate the code because it doesn't know how to print T. You must add the bound T: std::fmt::Display.

Link Time Optimization helps mitigate code bloat. If two crates both use Vec<i32>, the compiler generates Vec<i32> code in each crate. The linker merges the duplicates into a single copy. This reduces the final binary size. Enable LTO in your Cargo.toml for release builds to get this benefit.

Convention aside: The community convention is to omit #[inline] on generic functions. The compiler's auto-inliner is tuned for monomorphization. It inlines aggressively when it sees the concrete types. Adding the attribute usually hurts compile times without improving runtime performance. Trust the compiler.

Decision: generics versus alternatives

Use generics when you need type safety and zero runtime overhead, and the set of types is known at compile time. Use generics for collections, algorithms, and wrappers where performance matters.

Use dyn Trait when you need to store heterogeneous types in a collection, or when code bloat from monomorphization is unacceptable. Use dynamic dispatch for plugins, file formats, and UI systems where types are discovered at runtime.

Use concrete types when the logic is specific to one type and adding generics adds no reuse value. Concrete types compile faster and produce smaller binaries.

Use const generics when the type is the same but the size varies, like arrays of different lengths. Const generics monomorphize based on the constant value, not the type.

Where to go next