Storm set out to be the most enjoyable ORM to work with, entities as plain records, queries that read like the SQL they produce. This page shows that the same design keeps the hot path lean, measured against six alternatives on identical workloads, with the code behind every number.
Seven implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. Mean latency per operation, lower is better. Cells are tinted by distance from the fastest framework in the row, green through red. Percentages are overhead over raw JDBC. Raw JDBC is the reference floor.
| JDBC | Storm | Hibernate | jOOQ | Exposed | Exposed DAO | Jimmer | |
|---|---|---|---|---|---|---|---|
| Primary key lookup | 172 µs | 182 µs+6% | 181 µs+5% | 184 µs+7% | 370 µs+115% | 377 µs+119% | 184 µs+7% |
| Three-table join · 10 rows | 388 µs | 444 µs+14% | 448 µs+15% | 476 µs+23% | 579 µs+49% | 798 µs+105% | 606 µs+56% |
| Three-table join · 100 rows | 545 µs | 804 µs+47% | 1.17 ms+115% | 1.04 ms+91% | 814 µs+49% | 1.31 ms+141% | 981 µs+80% |
| Three-table join · 1,000 rows | 1.44 ms | 3.16 ms+120% | 3.67 ms+154% | 3.79 ms+163% | 2.79 ms+94% | 8.22 ms+471% | 8.45 ms+486% |
| Projection | 729 µs | 786 µs+8% | 757 µs+4% | 788 µs+8% | 1.01 ms+39% | 1.03 ms+42% | 758 µs+4% |
| Batch insert | 2.40 ms | 2.72 ms+13% | 3.54 ms+47% | 2.44 ms+1% | 3.17 ms+32% | 3.99 ms+66% | 3.74 ms+56% |
| Read, modify, update | 538 µs | 572 µs+6% | 557 µs+3% | 726 µs+35% | 553 µs+3% | 564 µs+5% | 901 µs+67% |
| Object graph | 855 µs | 1.19 ms+39% | 1.33 ms+56% | 972 µs+14% | 1.33 ms+55% | 1.38 ms+62% | 1.41 ms+65% |
| Average over JDBC | baseline | +32% | +50% | +43% | +54% | +126% | +103% |
Every library has strong rows, but Storm's is the only column that never runs hot. On every workload Storm is either the fastest framework or close behind it, while every alternative has at least one workload where it costs a third more than the best, and most cost far more than that. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.
Compare within a chart; each chart is a same-session comparison.
Load one visit by primary key. The purest round-trip test: one query, one row.
Load pets with owner and city hydrated through a single three-table join.
The same join at 100 rows. Hydration cost starts to separate the field.
The same join at 1,000 rows. Row mapping now dominates the round trip.
Three columns across three tables into a flat DTO, 100 rows.
Insert 100 visits atomically and fetch the generated keys.
Read one owner, change one field, persist atomically. Every implementation reads a lazy association shape and writes only the changed column.
Load the owners of a city, each with their list of pets. The one-to-many shape every application has.
Numbers without code invite tuned-benchmark suspicion, so here is what each workload runs, trimmed of harness plumbing. Toggle Show SQL to see the exact statement Storm puts on the wire. The full sources for all seven implementations are in the benchmark repository.
The entity or table definitions by the same counting rule, result DTOs and Storm's optimized write shape excluded. This is the code you write first and read forever.
JDBC and jOOQ have no hand-written model: JDBC maps rows by hand and jOOQ generates its table classes, so their cost appears in the suite total above instead.
Everything above the model that the eight workloads need: query code, row mappers, result DTOs and Storm's optimized write shape. Generated code is excluded for both libraries that use it: Storm's metamodel and jOOQ's table classes.
Storm implements all eight workloads in the fewest lines; every other implementation needs 23% to 157% more. Beyond the line count, the labels show what a low number can leave unsaid: hand-mapped rows are written and maintained by hand, and string queries are not compile-checked.
Plain data classes. Nullability, keys and relations live in the type: @FK val owner: Owner hydrates through a join, Ref<PetType> stays a lazy reference until asked. There are no proxies, no session lifecycle, and nothing to configure.
data class City( @PK val id: Long = 0, val name: String, ) : Entity<Long> data class Owner( @PK val id: Long = 0, val firstName: String, val lastName: String, val address: String, val telephone: String, @FK val city: City, ) : Entity<Long> data class Pet( @PK val id: Long = 0, val name: String, val birthDate: LocalDate, @FK val type: Ref<PetType>, @FK val owner: Owner, ) : Entity<Long>
val visit = visits.getById(id)
SELECT v.id, v.pet_id, v.visit_date, v.description FROM visit v WHERE v.id = ?
No fetch joins to spell out and no N+1 to dodge: the entity graph declares what a Pet is, so selecting pets hydrates owner and city from one query.
val result = pets.select() .where((Pet_.id greater base) and (Pet_.id lessEq base + rows)) .resultList // Pet, Owner and City hydrated from one query
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE p.id > ? AND p.id <= ?
A template picks three columns across the graph; the metamodel keeps every path compile-checked.
data class PetRow(val petName: String, val ownerLastName: String, val cityName: String) val rows = orm.selectFrom<Pet, PetRow> { "${Pet_.name}, ${Pet_.owner.lastName}, ${Pet_.owner.city.name}" } .where(Pet_.owner.city.id eq cityId) .resultList
SELECT p.name, o.last_name, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE o.city_id = ?
val ids = transaction { visits.insertAndFetchIds(newVisits) // 100 visits, one atomic batch }
INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?)
Storm's regular Owner is an aggregate: reading one loads its city through a join. Every other library declares that association lazy and reads the owner row alone, so to keep the read side of this workload identical for everyone, the benchmark uses a dedicated shape of the same table where city stays a lazy Ref. That shape is one record; declaring it is Storm's equivalent of the FetchType.LAZY the others put on their entities, and its ten lines are counted against Storm in the Queries LOC table above. On the write side, @DynamicUpdate(FIELD) writes only the column that changed. Entities are immutable; an update is a copy.
@DbTable("owner") // in-place optimization for writing: city as lazy ref @DynamicUpdate(UpdateMode.FIELD) data class OwnerCityRef( @PK val id: Long = 0, val firstName: String, val lastName: String, val address: String, val telephone: String, @FK val city: Ref<City>, ) : Entity<Long> transaction { val owner = owners.getById(id) owners.update(owner.copy(telephone = newTelephone)) }
SELECT ocr.id, ocr.first_name, ocr.last_name, ocr.address, ocr.telephone, ocr.city_id FROM owner ocr WHERE ocr.id = ? UPDATE owner SET telephone = ? WHERE id = ?
One query, grouped during hydration. Repeated owners deduplicate to the same instance, so grouping is an identity operation, not a hash of every field. Switch the variant to see the code the other libraries needed for the same result.
val result = pets.select() .where(Pet_.owner.city.id eq cityId) .orderBy(Pet_.owner) .resultGroupedBy(Pet_.owner) .map { (owner, pets) -> OwnerWithPets(owner, pets) }
List<Owner> owners = sessionFactory.fromSession(session -> session .createSelectionQuery( "select distinct o from Owner o join fetch o.pets join fetch o.city where o.city.id = :cityId", Owner.class) .setParameter("cityId", cityId) .getResultList()); // plus the @OneToMany(mappedBy = "owner") collection on the 126-line entity model
List<OwnerWithPets> result = ctx.select( OWNER.ID, OWNER.FIRST_NAME, OWNER.LAST_NAME, OWNER.ADDRESS, OWNER.TELEPHONE, CITY.ID, CITY.NAME, multiset( select(PET.ID, PET.NAME, PET.BIRTH_DATE, PET.TYPE_ID) .from(PET) .where(PET.OWNER_ID.eq(OWNER.ID)))) .from(OWNER) .join(CITY).on(OWNER.CITY_ID.eq(CITY.ID)) .where(OWNER.CITY_ID.eq(cityId)) .orderBy(OWNER.ID) .fetch(record -> { Owner owner = new Owner(record.value1(), record.value2(), record.value3(), record.value4(), record.value5(), new City(record.value6(), record.value7())); List<Pet> pets = record.value8() .map(pet -> new Pet(pet.value1(), pet.value2(), pet.value3(), pet.value4(), owner)); return new OwnerWithPets(owner, pets); });
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id, o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name FROM pet p INNER JOIN owner o ON p.owner_id = o.id INNER JOIN city c ON o.city_id = c.id WHERE o.city_id = ? ORDER BY p.owner_id
The suite is built to be argued with. Everything below is enforced in code, not prose.
join fetch and @DynamicUpdate, jOOQ with generated records and MULTISET, Jimmer with fetchers, Exposed in both DSL and DAO flavors.Versions: Storm 1.13.0, Hibernate 7.4.5, jOOQ 3.21.6, Exposed 1.3.1, Jimmer 0.11.0, PostgreSQL 17, JDK 21.
Reproduce it: git clone https://github.com/storm-orm/storm-benchmarks && scripts/run.sh. The repository contains the full methodology, the statement-log auditing tools used to verify round-trip counts, and every implementation in full.