Rust dyn Trait vs generics: how to switch, and the 16-byte cost
Rust dyn Trait vs generics: how to switch, and the 16-byte fat-pointer cost dyn Trait pays on every call — the cost generics compile away.

TL;DR Rust dyn Trait vs generics comes down to one number: dyn Trait is a 16-byte fat pointer — a data pointer plus a vtable pointer — twice the size of the plain 8-byte reference generics compile down to. Generics pay their cost at compile time (monomorphization: one function body per concrete type you call with), while dyn Trait pays it on every call instead (one vtable load plus an indirect jump). Use dyn Trait only where you need one collection or return type to hold genuinely different concrete types at once; some traits — anything with a method returning Self, or a generic method — can’t become trait objects at all, no matter which one you’d prefer.
Every Rust codebase eventually hits the same fork: a Draw trait implemented by Circle, Square, and Triangle, and a function that needs to draw any of them. Generics and dyn Trait both compile — the compiler is happy to accept either — but they solve different problems, and picking the wrong one shows up either as a binary bigger than it needs to be, or as a Vec<T> that won’t compile because its elements aren’t all the same concrete type.
This is the same trade-off you run into when you’re staring at struct layout and padding or deciding how much unsafe is worth a performance win — except here the compiler enforces the boundary whether you understand why or not. So here’s the trade-off made explicit: what dyn Trait actually costs, measured in bytes, and the rule for when that cost is worth paying.
What dyn Trait actually costs in memory
A plain Rust reference, &T, is a thin pointer — 8 bytes on a 64-bit target, holding nothing but the address of the value. Write &dyn Draw instead and the compiler hands you a fat pointer: 16 bytes, twice the size, because it now carries two addresses instead of one — a pointer to the concrete value’s data, and a pointer to that type’s vtable, a static table of function pointers used to find the right draw() implementation for whatever concrete type is actually behind the reference.
You can verify this yourself, no benchmark required: std::mem::size_of::<&dyn Draw>() reports 16 on any 64-bit target, against 8 for std::mem::size_of::<&Circle>(). Box<dyn Draw> costs the same 16 bytes for the pointer, plus whatever the concrete value needs on the heap — boxing a trait object doesn’t make the fat pointer thinner, it just adds ownership of whatever it points to.
The detail that trips people up: the vtable is keyed on the (concrete type, trait) pair, not on the type alone. If Duck implements both Fly and Swim, a &dyn Fly and a &dyn Swim built from the same duck value carry an identical data pointer but two different vtable pointers — one full of Fly’s methods, one full of Swim‘s. There’s no single “the vtable” for a type; there’s one per trait it’s viewed through.
How static dispatch avoids the cost — and what it costs instead
Write the same function generically — fn draw_shape<T: Draw>(shape: &T) — and the compiler does something completely different: it emits a separate compiled copy of the function for every concrete type you actually call it with. draw_shape::<Circle> and draw_shape::<Square> become two distinct functions in the binary, and each one calls Circle::draw or Square::draw directly, with no lookup at all. This is monomorphization, and it’s the reason generics in Rust are called a zero-cost abstraction: by the time the program runs, there’s no polymorphism left to resolve — it happened at compile time.
The cost doesn’t disappear, it moves. Every additional concrete type a generic function gets instantiated with is another compiled function body in your binary — ten call sites with ten different types produce ten function bodies, not one. That’s a compile-time and binary-size cost, not a runtime one, which is the opposite trade from dyn Trait: one function body, paid for with a vtable load and an indirect call on every use.
Rust also draws this line in a different place than C++. In C++, a class either has virtual methods or it doesn’t — the choice is baked into the class definition, and every instance carries a vtable pointer whether or not you ever call through it dynamically. In Rust, the same type can be used generically in one function and boxed behind dyn in another; the choice is made per call site — &dyn Trait or Box<dyn Trait> — not per type definition.
The decision table: dyn Trait vs generics
| Axis | Generics (<T: Trait> / impl Trait) | dyn Trait |
|---|---|---|
| Reference size | 8 bytes (thin) | 16 bytes (fat: data + vtable) |
| Dispatch cost per call | None — resolved at compile time | One vtable load + indirect call |
| Binary size | Grows with each concrete type instantiated | One function body, regardless of how many types implement the trait |
Heterogeneous collections (Vec<Box<dyn Trait>>) | Not directly possible — a Vec<T> needs one concrete T | The whole point — different concrete types in one collection |
| Generic methods on the trait | Supported | Not supported — breaks object safety |
| Compile time | Increases with each instantiation | Unaffected by how many types implement the trait |
Nothing in this table is a tie-breaker by itself — it’s the input to the one question that actually decides it.
Rust dyn Trait vs generics: when should you switch?
Reach for dyn Trait when you need one collection, field, or return type to hold genuinely different concrete types at runtime — a plugin registry, a list of UI widgets, a set of parsers chosen by content type, a callback registered by code you don’t control and can’t monomorphize against. That’s the case dyn Trait exists to solve, and generics can’t solve it at all: Vec<T> requires every element to be the same concrete T.
Reach for generics everywhere else, including the default case of “one call site, one concrete type at a time.” You get the same abstraction over the trait’s methods with zero per-call cost, and the compiler catches a bound mismatch immediately at the call site — it doesn’t wait until you try to build a heterogeneous Vec to tell you something doesn’t fit. If you’re not sure yet whether you’ll ever need more than one concrete type behind a given reference, start generic; switching to dyn Trait later is a smaller change than the reverse.
What breaks if you default to dyn Trait everywhere?
The most common mistake is reaching for Box<dyn Trait> out of habit and then hitting E0038: the trait cannot be made into an object — the compiler refusing to build a vtable for a trait that isn’t object-safe (covered next). The fix is almost never “force it”; it’s picking generics for that call site instead, or restructuring the trait.
The second mistake is subtler: assuming a dyn Trait reference is “basically just a pointer” and forgetting the doubling. A struct with several Box<dyn Trait> fields is measurably bigger than the same struct built around an enum of concrete variants, and that adds up across a large collection of such structs.
The third is a cache-locality problem, not a dispatch-cost one. Vec<Box<dyn Trait>> scatters its elements across independent heap allocations — iterating it means chasing a different, unpredictable address on every step, on top of the vtable jump itself. Vec<ConcreteType> (or an enum, if the type set is closed) keeps its elements contiguous in memory, and that locality is usually worth more in a hot loop than avoiding one indirect call.
Object safety: why some traits can’t become trait objects
Two patterns disqualify a trait from ever becoming dyn Trait, and both come down to the same problem: the compiler can’t build a fixed-size vtable entry for them.
- A method that returns
Self.Clone::clone(&self) -> Selfneeds the caller to know the concrete type’s size to allocate the returned value — but behind a&dyn Trait, all the caller has is a data pointer and a vtable. That’s exactly whyClonealone can’t be a trait object; the standard workaround is a second, object-safe trait with aclone_box(&self) -> Box<dyn Trait>method that returns a boxed value instead ofSelfdirectly. - A generic method.
fn serialize<T: Write>(&self, out: &mut T)would need one vtable entry per typeTthe method is ever called with — an unbounded, open-ended set the compiler can’t enumerate ahead of time, so it refuses to generate a vtable at all.
Both rules exist for the same reason: a vtable is a fixed-size table decided once at compile time, and anything whose shape depends on information only available at the call site can’t fit in one.
Converting a generic function to dyn Trait, step by step
- Check object safety first. Does the trait have any method returning
Self, or any generic method? If yes, you’ll need a second trait (an object-safe subset) beforedyn Traitwill compile. - Change the signature.
fn draw_shape<T: Draw>(shape: &T)becomesfn draw_shape(shape: &dyn Draw), orBox<dyn Draw>if the function needs to own the value. - Update call sites. Concrete values now need an explicit
&circleorBox::new(circle)where a bare value used to satisfy a generic bound directly. - Watch the lifetime bound.
Box<dyn Draw>implicitly requiresdyn Draw + 'staticunless you write out a shorter lifetime — a common compile error the first time you make this switch. - Re-measure the hot path, don’t assume. If this function runs in a loop that matters, benchmark before and after — the vtable jump itself is rarely the story; a scattered
Vec<Box<dyn Draw>>replacing a contiguousVec<Circle>usually is.
// Before: generic, monomorphized per concrete type
fn draw_shape<T: Draw>(shape: &T) {
shape.draw();
}
// After: dyn Trait, one function body, one vtable jump per call
fn draw_shape(shape: &dyn Draw) {
shape.draw();
}
// Now the caller can hold a Vec of genuinely different shapes:
let shapes: Vec<Box<dyn Draw>> = vec![Box::new(Circle), Box::new(Square)];
for shape in &shapes {
draw_shape(shape.as_ref());
}FAQ
What is a fat pointer in Rust?
A fat pointer is a reference that carries two addresses instead of one. &dyn Trait is the most common example: one word points to the value’s data, the other points to that type’s vtable. A plain reference like &T is a thin pointer — a single 8-byte address — because the compiler already knows T’s layout and methods at compile time and has nothing extra to attach.
Does Box<dyn Trait> cost more than &dyn Trait?
The pointer itself is the same 16 bytes in both cases — a Box is still a fat pointer when it points at a trait object. The difference is ownership: Box<dyn Trait> also heap-allocates and owns the underlying value, while &dyn Trait only borrows a value that lives somewhere else. Neither one makes the fat pointer thinner.
Why can’t Clone be used as a trait object?
Clone::clone returns Self, and behind a &dyn Trait the caller only knows the vtable and a data pointer — it has no way to know how many bytes Self needs to allocate for the returned value. Rust’s object-safety rule bans any method that returns Self for exactly this reason. The usual workaround is a second trait with a clone_box(&self) -> Box<dyn Trait> method, which returns a fixed-size, object-safe type instead of Self.
Does using generics instead of dyn Trait always make binaries bigger?
Only if you call the generic function with many different concrete types — monomorphization compiles one function body per type actually used, so ten call sites with ten types produce ten function bodies. A generic function called with one or two types costs about the same as a non-generic one. dyn Trait keeps exactly one function body no matter how many types implement the trait, which is the trade you’re making in the other direction.
Is the vtable lookup in dyn Trait actually slow?
In isolation, one indirect call through a vtable is a handful of nanoseconds — rarely the bottleneck by itself. The cost that actually shows up in practice is indirect: a Vec<Box<dyn Trait>> scatters its elements across separate heap allocations, so iterating it means chasing a different, unpredictable address on every step, which is what actually hurts cache behavior in a hot loop. A Vec<ConcreteType> keeps its elements contiguous and doesn’t pay that price.
Can I mix dyn Trait and generics in the same codebase?
Yes, and most real Rust codebases do. The choice is made per call site, not per type — the same type can implement a trait and be used generically in one function while being boxed as a dyn Trait in another. Pick generics as the default for a single-type call path and reach for dyn Trait only at the specific boundary where you need one collection or return type to hold genuinely different concrete types.
Sources
- Visualizing Rust’s Vtables: How dyn Trait Works In Memory — the fat-pointer and per-(type, trait)-vtable details this post builds on.
- The Rust Reference — Trait objects — the formal definition of trait objects and object safety.
- Rust error code E0038 — the compiler’s own explanation of why a trait fails to be object-safe.
If you’re weighing this alongside other memory-layout decisions, cutting a Rust struct’s footprint and Node.js’s pointer-compression trade-off are the same kind of “make the cost explicit, then decide” exercise applied to different problems. And if the LSP you’re running to catch these decisions is itself memory-hungry, Glancer on 8GB of RAM is worth a look.
Frequently asked questions
Google Search · Preferred sources
Prefer this site on Google
If you already read this writing, add umesh-malik.com as a Preferred Source. Google can then highlight it with a preferred badge in Top Stories, AI Overviews, and AI Mode — for you, not as a site-wide ranking boost.
Related Articles

Web Engineering
How to Reduce Rust Struct Memory Footprint: 5 Techniques, 56% Smaller
How to reduce Rust struct memory footprint: the 5 layout changes that shrank a real cache entry from 953 to 420 bytes, and what each one costs.

Web Engineering
How to Give an AI Agent CMS Write Access Without Melting the Cache
AI agent CMS write access breaks caches fast. The layered invalidation pattern that let one CMS absorb 5,000 RPS spikes and a 28,000 RPS DDoS without a hiccup.

Web Engineering
SVG to MP4 in the browser: a two-step workflow, no server
SVG to MP4 in the browser needs no server: paste a URL and 30MB of ffmpeg.wasm renders every frame in your tab. The one catch that trips people up.
Keep reading
Get new posts on AI, Claude Code & LLMs
New deep-dives on AI engineering, Claude Code, and developer tooling — follow along however you prefer.
About the Author
Software engineer writing about AI, Claude Code, LLMs, OpenAI, Anthropic, and developer tooling. 5+ years building production systems at Expedia Group, Tekion, and BYJU'S.