Generics in Rust and How Monomorphization Makes Them Fast
Generic Rust code is compiled once per concrete type, which is why it is as fast as hand written code and why binaries grow. See what that looks like.

Generic code in Rust is written once and runs as fast as hand-written specialized code. That is not a slogan but a consequence of how the compiler handles generics: for every concrete type you use a generic function with, it generates a separate copy of the function, with the type filled in and optimized as if you had written it by hand. This is called monomorphization. It is why Vec<u8> and Vec<String> each behave like a purpose-built vector, and it is the reason generics have a cost that does not show up as run-time speed: they show up as compile time and binary size. This post makes the copies visible with a symbol listing, explains what you get and what you pay, and shows the case where you should choose a different tool.
The programs ran on Rust 1.99 with edition 2024, built with -O, on an Apple silicon Mac.
One function, three copies
Here is a generic function that finds the largest element of a slice, called with three different element types. It is marked #[inline(never)] so the optimizer does not fold the copies into main and hide them:
use std::hint::black_box;
#[inline(never)]
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut best = items[0];
for &item in &items[1..] {
if item > best {
best = item;
}
}
best
}
fn main() {
let ints = [3, 9, 4];
let floats = [2.5, 0.5, 8.25];
let chars = ['x', 'b', 'q'];
println!("{}", largest(black_box(&ints)));
println!("{}", largest(black_box(&floats)));
println!("{}", largest(black_box(&chars)));
}9
8.25
xThe function largest<T> is written once. The output shows it working for integers, floats and characters. Now ask the compiler what it produced. The nm tool lists the symbols in the compiled binary, and -C demangles Rust's names:
nm -C t163 | grep largest00000001000008c0 t t163::largest::<char>
000000010000091c t t163::largest::<f64>
0000000100000980 t t163::largest::<i32>There are three functions, not one. The compiler saw three calls, with T = i32, T = f64 and T = char, and generated three separate bodies. Each is specialized: the i32 version uses integer compare instructions, the f64 version uses floating-point compares, and the char version compares 32-bit scalar values. The generic source never exists at run time. The addresses show them as separate pieces of machine code of a few dozen bytes each.
This is the sense in which generics are zero-cost. The call largest(&ints) goes straight to the i32 copy. There is no type tag to check, no boxing and no virtual call. The bound T: PartialOrd + Copy is checked during compilation, and after that it has no run-time presence.
What monomorphization gives you
Speed, first. Because each copy is a normal function on a known type, the optimizer can inline it, unroll its loop, vectorize it and propagate constants through it. The comparison item > best compiles to the single instruction for that type, not a call to a comparison function.
Early errors, second. A generic function is type-checked against its bounds when you write it, and again at each use. A call with a type that does not satisfy the bounds fails at the call site with a message naming the missing trait, instead of failing at run time or deep inside a template expansion as in C++.
Specialization by behavior, third. The same generic code does the right thing for each type's own implementations. Vec<T> calls T's Drop and Clone directly, not through a table, because it is compiled for that T.
What it costs
The costs are compile time and code size, and both grow with the number of distinct types, not the number of calls. Every new instantiation is another function to compile and optimize and another body in the binary. The standard library's Vec, HashMap and Option are instantiated for each type you use, and a large program with hundreds of types can spend a substantial part of its build time on generated copies. Binary size follows: a generic function with a 500-byte body used with 20 types adds roughly 10 KB. For a service, that is nothing. For firmware with 64 KiB of flash it is a design constraint; the size-measuring methods in Rust firmware size optimization show a case where four instantiations of a small generic function cost 256 bytes, and a trait-object version cost more.
Instruction cache pressure is the subtler cost. Many near-identical copies of a hot function compete for cache. This shows up in programs that instantiate the same large generic with dozens of types in a hot path, and it is one reason the compiler team works on merging identical copies.
A trait object avoids the copies and costs a call
The alternative to monomorphization is dynamic dispatch, where one compiled function handles all types and finds the behavior it needs at run time through a table of function pointers. The program below takes a slice of &dyn Fn(u64) -> u64, a trait object, and applies each:
use std::hint::black_box;
fn sum_dyn(items: &[&dyn Fn(u64) -> u64], x: u64) -> u64 {
items.iter().map(|f| f(x)).sum()
}
fn main() {
let add = |n: u64| n + 1;
let mul = |n: u64| n * 2;
println!("{}", sum_dyn(&[&add, &mul], black_box(10)));
}31It prints 31, which is (10 + 1) + (10 * 2). There is exactly one sum_dyn function in the binary however many closure types you pass it, and each call goes through the table. That saves code size and costs an indirect call that the optimizer usually cannot inline. Static versus dynamic dispatch measures the difference, and the right answer depends on the call pattern.
Keeping the bloat down
Most programs never need to think about it. When you do, a few techniques keep generic code from multiplying.
Move the non-generic part out. A generic function that does ten lines of type-specific work and a hundred lines of type-independent work should call a non-generic inner function for the hundred. The standard library does this: fn read_to_string<P: AsRef<Path>>(path: P) immediately converts path to &Path and calls a non-generic function, so the heavy body is compiled once, and only the thin wrapper is duplicated per P.
pub fn read_config<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<String> {
fn inner(path: &std::path::Path) -> std::io::Result<String> {
std::fs::read_to_string(path) // the real work, compiled once
}
inner(path.as_ref())
}Use &dyn Trait for code where the call is not hot, such as logging, error reporting and plugin points. Prefer a small set of concrete types where you control the interface. And look at what is actually duplicated before acting: cargo llvm-lines (a Cargo subcommand) lists the functions in your crate by the number of lines of generated code, which tells you which generic is responsible.
Bounds you will write most often
Monomorphization is what makes trait bounds meaningful, so a quick map is useful. T: Clone means "the compiled copy can call T::clone". T: Copy means values can be duplicated by bit copy, so the generated code moves them freely. T: Send is a marker with no code: it only gates whether the call compiles for a given thread usage. T: 'static limits what the type may borrow. When you meet an error about a missing bound, trait bounds and where clauses walks through how to add it correctly.
When generics are the wrong tool
If the set of types is closed and small, an enum removes the question entirely and avoids both the copies and the indirect call; see enum dispatch. If you need a collection of values of different types, generics cannot help, because a Vec<T> holds one T; you need a trait object or an enum. If compile time is the problem, measure first with cargo build --timings, since generics are only one cause. And if the function is called twice in the whole program, the choice does not matter; write the version that reads best.


