Viktar Patotski Viktar Patotski · · performance  · 6 min read

MapStruct vs ModelMapper vs Orika: I Measured the Mapping Tax

Four ways to map a Java bean, one JMH harness, real numbers. MapStruct costs almost nothing over hand-written code. One of the others is about 300x slower. Here is which, and when it matters.

Four ways to map a Java bean, one JMH harness, real numbers. MapStruct costs almost nothing over hand-written code. One of the others is about 300x slower. Here is which, and when it matters.

Every Java service that talks to the outside world maps beans: entity to DTO, DTO to response, request to command. You can do it by hand, or reach for a library so you stop writing dto.setX(source.getX()) forty times. The usual three are MapStruct, ModelMapper, and Orika. They feel interchangeable in a code review. They are not. Under load the gap between them is about 300x, and it comes straight from how each one does its job.

So I benchmarked all three, plus a hand-written mapper as the control, with JMH. Same object, same machine, real numbers.

The contenders

MapStruct is a compile-time annotation processor. You declare a @Mapper interface, and at build time it generates the plain, boring Java you would have written by hand (PersonMapperImpl with a pile of getters and setters). At run time there is no library doing anything clever, just generated code.

ModelMapper works at run time by reflection. You hand it a source and a target class and it matches fields by convention. Zero build-time setup, very little code, but every mapping call inspects the objects through the reflection API.

Orika also works at run time, but generates bytecode (via Javassist) for each mapping instead of reflecting on every call. In theory that should land between the other two. One catch up front: Orika is unmaintained. Its last release, 1.5.4, was February 2019, and it shows (more on that below).

Hand-written is the control: plain Java, no library. It is the floor. The only honest way to read “fast” is against the cost of doing it yourself.

How I measured

JMH, the standard Java microbenchmark harness. AverageTime mode, nanoseconds per mapping, 5 forks of 5 warmup and 5 measurement iterations (25 samples per mapper), one thread, with the GC profiler on so allocation per call is measured alongside time. The mappers are built once in setup, so the benchmark measures steady-state mapping cost, not configuration.

The fixture is not a toy two-field DTO, because that would flatter everything. It is a Person with 11 fields: a couple of primitives, some strings, a LocalDate, an Instant, a nested Address object, and a list of roles, mapped to a matching PersonDto. Hardware and versions are disclosed because reproducibility is the point: AMD Ryzen 9 7950X3D, 96 GB RAM, Amazon Corretto JDK 25.0.3, JMH 1.37, MapStruct 1.6.3, ModelMapper 3.2.6, Orika 1.5.4. The full harness is in the repo; clone it and run it on your own box.

The numbers

Time to map one object, lower is better:

MapperApproachns per mappingerror (ns)vs hand-writtenbytes allocated per mapping
Hand-writtenplain Java12.94±0.301.00x176
MapStructcompile-time codegen13.50±0.171.04x176
Orikaruntime bytecode174.78±3.4013.5x744
ModelMapperruntime reflection4,074.94±52.97315x12,560

Bar chart of mapping time per object. Hand-written 12.9 ns, MapStruct 13.5 ns, Orika 174.8 ns, ModelMapper 4,075 ns. ModelMapper's bar dwarfs the rest.

The chart is almost useless at first glance, and that is the point: ModelMapper’s bar is so long the other three are slivers. That is the story. Reflection on every call costs about 4,100 nanoseconds here; the other approaches are in the tens to low hundreds.

Zoom in on the three fast ones and the rest of the shape appears:

Bar chart zoomed to the three fast mappers. Hand-written 12.9 ns, MapStruct 13.5 ns, Orika 174.8 ns.

What the numbers mean

MapStruct is indistinguishable from hand-written in practice. 13.5 ns versus 12.9, a 4% gap that sits inside the noise, and the allocation column settles it: both allocate exactly 176 bytes per mapping. The generated code creates precisely what you would create by hand (the DTO, the nested AddressDto, the roles list) and nothing else. That is the whole argument for it: because the mapping code is generated at compile time, there is no run-time machinery to pay for, in time or in garbage. You get the convenience of declaring an interface and the performance of code you wrote yourself.

ModelMapper is about 300x slower than MapStruct, 315x hand-written. It is not broken and it is not badly written; reflection is just expensive, and it pays that cost on every single call. The allocation column says where the time goes: 12,560 bytes per mapping, 71x the floor. The slowdown is allocation-bound, not CPU-bound. The GC data has a telling inversion: ModelMapper shows the lowest allocation rate of the four, 2.9 GB/s against 12 to 13 GB/s for the fast pair, because it is too slow to allocate quickly. For a mapping that runs once per request on a cold path, 4 microseconds is invisible. For one that runs in a loop over a result set, or on a hot request path at scale, it is a real tax you are paying for the convenience of skipping a build step.

Orika lands in the middle at 13.5x, allocating 744 bytes per mapping, 4.2x the floor. The bytecode-generated mappers are real, but you pay for MapperFacade dispatch and a per-call context object on top: better than reflection, worse than generated-at-compile-time code. It is hard to recommend in 2026, and not only on speed.

The Orika catch

Orika did not run at all on JDK 25 until I added a JVM flag:

--add-opens=java.base/java.lang=ALL-UNNAMED

It reflects into java.lang internals (to clone objects), and the JDK module system has blocked that by default since JDK 16. A maintained library would have fixed this years ago. Orika has not shipped a release since 2019, so you inherit the workaround. The maintained libraries in this test need no such flag. To keep the comparison fair I applied it to all four benchmarks anyway, so the flag itself introduces no bias. Treat Orika as a data point about the runtime-bytecode approach, not as a live option for new code.

The honest verdict

Fastest is not the only axis, so be fair about what the slower libraries buy you. ModelMapper needs no annotation processor and no build-time step, and its convention-based mapping means less code for loose, forgiving transformations. On a cold path where a few microseconds do not matter, that convenience is a legitimate choice.

But if mapping is anywhere near a hot path, the numbers are not close. MapStruct costs almost nothing over writing the mapper by hand, and it catches missing mappings at compile time instead of at run time. For most services it is the right default, and this benchmark is why. Orika is a museum piece. ModelMapper is a convenience you should price before you put it in a loop.

The harness, the fixture, and the raw results are in the jvm-benchmarks repo. Run it on your hardware and check the numbers.


This is the first in a series of JMH library benchmarks. If your team is fighting a performance problem that is less about mapping and more about the whole request path, that is the kind of thing I do under Performance Engineering. Related on this site: Spring Boot on the JVM vs GraalVM Native.

Back to Blog

Related Posts

View All Posts »
Performance Viktar Patotski Viktar Patotski · 13 min read

Virtual Threads vs Platform Threads: A Spring Boot Benchmark on AWS

Do Java virtual threads make a Spring Boot app faster? I load-tested the same app both ways on real AWS hardware. The honest answer: below saturation they do nothing, on CPU-bound work they do nothing, and where they win on blocking I/O, a properly tuned platform pool matches the throughput. The real case for virtual threads is not raw speed, it is having no pool to tune and a cheaper thread.

Performance Viktar Patotski Viktar Patotski · 9 min read

Spring Boot on the JVM vs GraalVM Native: What Actually Wins on AWS

A head-to-head benchmark of the same Spring Boot app built for the JVM and as a GraalVM native binary - on real AWS hardware with a real database, run multiple times. Native wins startup, memory, and predictability; the warm JVM wins the median, peak throughput, and often the tail too - but the JVM swings run-to-run while native stays flat.