What is monomorphization

Monomorphization is the Rust compiler's process of generating specific, optimized code for each type used with a generic function or struct at compile time.

The mold that disappears

You write a generic function to find the maximum value in a list. It works for integers. It works for strings. It works for your custom struct as long as that struct implements comparison. You call the function three times with three different types. You expect the compiler to keep one function that handles everything.

The compiler does not keep one function. It generates three separate functions. One for integers, one for strings, one for your struct. The generic placeholder vanishes. The binary contains only concrete, type-specific code. This process is monomorphization.

The word comes from "mono" meaning one and "morph" meaning shape. The compiler takes a generic shape and stamps out one concrete shape for every type you actually use. The generic code is a mold. The monomorphized code is the cast object. Once the casting is done, the mold is thrown away.

How the compiler stamps out code

Monomorphization happens at compile time. The compiler never emits code that contains a generic parameter T. Every instance of T gets replaced with a real type before the machine code is generated. This replacement unlocks optimizations that are impossible with runtime polymorphism.

When the compiler generates code for a specific type, it knows the exact size of that type. It knows whether the type can be copied with a single register move or requires a heap allocation. It knows which CPU instructions are most efficient for the type. The compiler can inline the function, unroll loops, and eliminate bounds checks based on the concrete type.

Generics are a zero-cost abstraction. The code runs as fast as if you had written the function by hand for each type. There is no runtime dispatch table. There is no virtual function call overhead. The cost is paid at compile time in the form of code generation and potentially larger binaries.

/// Finds the largest element in a slice.
/// The compiler generates a separate function for each T used.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    // T is a placeholder. The compiler does not know the size yet.
    // It relies on the trait bound to know comparison is possible.
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let nums = [10, 5, 20, 3];
    // The compiler infers T = i32.
    // It generates largest::<i32> with machine code for 32-bit integers.
    let max_num = largest(&nums);

    let words = ["apple", "banana", "cherry"];
    // The compiler infers T = &str.
    // It generates largest::<&str> with machine code for string slices.
    let max_word = largest(&words);
}

The function largest appears once in your source code. The binary contains two distinct functions. If you inspect the assembly, you will see largest_i32 and largest_str. The i32 version likely uses integer comparison instructions. The &str version calls a string comparison routine that checks length and bytes. The compiler tailors the implementation to the type.

A realistic data structure

Monomorphization shines in data structures. Collections like Vec<T> and HashMap<K, V> use generics to work with any type. The compiler generates a specialized version of the collection for each type you store. This specialization allows the collection to optimize memory layout and access patterns.

Consider a simple cache. The cache stores a value and returns a reference to it. If you use the cache with a small integer, the compiler generates code that stores the integer inline. If you use it with a large struct, the compiler generates code that handles the struct's size and alignment correctly.

/// A simple cache that holds one value.
/// Monomorphization creates Cache<i32>, Cache<String>, etc.
struct Cache<T> {
    value: Option<T>,
}

impl<T> Cache<T> {
    fn new() -> Self {
        Cache { value: None }
    }

    fn set(&mut self, val: T) {
        // The compiler generates code that moves val into value.
        // If T is Copy, this is a bitwise copy.
        // If T is a String, this moves the heap pointer.
        self.value = Some(val);
    }

    fn get(&self) -> Option<&T> {
        self.value.as_ref()
    }
}

fn main() {
    let mut int_cache = Cache::new();
    int_cache.set(42);
    // Cache<i32> is generated. The integer lives inside the struct.

    let mut str_cache = Cache::new();
    str_cache.set(String::from("hello"));
    // Cache<String> is generated. The struct holds a pointer to heap data.
}

The Cache struct has a different memory layout depending on T. For Cache<i32>, the struct contains an Option<i32>, which is just a few bytes on the stack. For Cache<String>, the struct contains an Option<String>, which holds a pointer, length, and capacity. The compiler generates the correct layout for each case. You get the efficiency of a hand-tuned struct for every type, without writing the code yourself.

When monomorphization bites

Monomorphization is not free. The compiler must generate code for every type you use. If you use a generic function with many types, the binary size grows. This is called code bloat. Each monomorphized instance adds instructions to the binary. If you have a large generic library and you instantiate it with dozens of types, the binary can swell significantly.

Compile time also increases. The compiler has to parse, type-check, and optimize more code. Heavy use of complex generics can slow down builds. The trade-off is runtime performance versus compile time and binary size.

Binary bloat is not always a problem. Modern linkers perform dead code elimination. If you monomorphize a function but never call it, the linker strips it from the final binary. The bloat only matters for the types you actually use. If your application uses a generic collection with ten different types, you pay for ten copies of the collection code.

The compiler rejects generic code that violates trait bounds. If you try to use a type that does not satisfy the requirements, you get a compile error. The error message tells you exactly which trait is missing.

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

struct MyData {
    x: i32,
}

fn main() {
    let data = [MyData { x: 1 }, MyData { x: 2 }];
    // Error: MyData does not implement PartialOrd.
    // The compiler cannot generate comparison code.
    let _max = largest(&data);
}

The compiler rejects this with E0277 (the trait bound MyData: PartialOrd is not satisfied). The error occurs because the compiler cannot generate the comparison logic. It needs to know how to compare MyData values. You must implement PartialOrd for MyData or choose a different type. The error is precise. It points to the missing trait and the location where it is required.

Convention aside: Name your generic parameters descriptively when T is not enough. The community expects K for keys, V for values, and E for errors. If you have multiple type parameters, use names that hint at their role. fn map<T, U>(...) is clear. fn map<A, B>(...) is less so. Follow the naming patterns in the standard library.

Another convention: Keep generic abstractions small. Large generic functions that are instantiated with many types contribute more to bloat. If a generic function is huge, consider whether you really need it to be generic. Sometimes a concrete implementation is better. Use tools like cargo bloat to measure the impact of generics on your binary size. If a single generic type is adding megabytes, refactor.

Generics are a compile-time contract. If you break the contract, the code does not compile. If you honor the contract, you get optimized code. The compiler does the heavy lifting. You get the performance.

Choosing your polymorphism

Rust offers multiple ways to handle multiple types. Monomorphization is one approach. Trait objects are another. Concrete types are a third. The choice depends on your needs.

Use generics when you need zero-cost polymorphism and the set of types is known at compile time. Generics give you the best runtime performance. The compiler inlines code and optimizes based on the concrete type. Use generics for collections, algorithms, and wrappers where performance matters.

Reach for trait objects (Box<dyn Trait>) when you need to store heterogeneous types in a collection or when binary size bloat from monomorphization is unacceptable. Trait objects use dynamic dispatch. The compiler generates one function that uses a vtable to call the correct method at runtime. This adds a small runtime cost but reduces code duplication. Use trait objects for plugin architectures, event systems, and UI frameworks where types are determined at runtime.

Pick concrete types when the function only ever works with one specific type. Generics add cognitive overhead and compile time without benefit. If a function only handles i32, write it for i32. Keep the API surface small and the code easy to read.

Use TypeId or Any when you absolutely must inspect types at runtime, though this usually signals a design that fights the type system. Runtime type identification is rare in idiomatic Rust. Prefer compile-time checks and trait bounds. If you find yourself reaching for runtime type checks, reconsider your abstraction.

Trust the type system. Static dispatch wins on speed. Dynamic dispatch wins on flexibility. Concrete types win on simplicity. Pick the tool that matches your constraints.

Where to go next