Concise by design.
Fast by measurement.

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.

PostgreSQL 17 over TCP · JMH · Storm 1.13.0 · measured 2026-07-14

+10 µsis all Storm adds to a 172 µs primary key lookup over raw JDBC. The abstraction is nearly free.
26% lessoverhead over raw JDBC than the closest alternative, averaged across all eight workloads.
72% lessentity code than JPA: the five-table model is 29 lines in Storm, 105 as JPA entities.

At a glance

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.

JDBCStormHibernatejOOQExposedExposed DAOJimmer
Primary key lookup172 µs182 µs+6%181 µs+5%184 µs+7%370 µs+115%377 µs+119%184 µs+7%
Three-table join · 10 rows388 µs444 µs+14%448 µs+15%476 µs+23%579 µs+49%798 µs+105%606 µs+56%
Three-table join · 100 rows545 µs804 µ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 rows1.44 ms3.16 ms+120%3.67 ms+154%3.79 ms+163%2.79 ms+94%8.22 ms+471%8.45 ms+486%
Projection729 µs786 µs+8%757 µs+4%788 µs+8%1.01 ms+39%1.03 ms+42%758 µs+4%
Batch insert2.40 ms2.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, update538 µs572 µs+6%557 µs+3%726 µs+35%553 µs+3%564 µs+5%901 µs+67%
Object graph855 µs1.19 ms+39%1.33 ms+56%972 µs+14%1.33 ms+55%1.38 ms+62%1.41 ms+65%
Average over JDBCbaseline+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.

Per-workload charts: the same numbers with their reported error

Compare within a chart; each chart is a same-session comparison.

Primary key lookup

Load one visit by primary key. The purest round-trip test: one query, one row.

JDBC 172 µs ± 6 µs
Hibernate 181 µs ± 5 µs
Storm 182 µs ± 7 µs
jOOQ 184 µs ± 4 µs
Jimmer 184 µs ± 8 µs
Exposed 370 µs ± 9 µs
Exposed DAO 377 µs ± 6 µs

Three-table join · 10 rows

Load pets with owner and city hydrated through a single three-table join.

JDBC 388 µs ± 7 µs
Storm 444 µs ± 9 µs
Hibernate 448 µs ± 8 µs
jOOQ 476 µs ± 11 µs
Exposed 579 µs ± 6 µs
Jimmer 606 µs ± 21 µs
Exposed DAO 798 µs ± 22 µs

Three-table join · 100 rows

The same join at 100 rows. Hydration cost starts to separate the field.

JDBC 545 µs ± 10 µs
Storm 804 µs ± 32 µs
Exposed 814 µs ± 8 µs
Jimmer 981 µs ± 15 µs
jOOQ 1.04 ms ± 111 µs
Hibernate 1.17 ms ± 242 µs
Exposed DAO 1.31 ms ± 344 µs

Three-table join · 1,000 rows

The same join at 1,000 rows. Row mapping now dominates the round trip.

JDBC 1.44 ms ± 102 µs
Exposed 2.79 ms ± 216 µs
Storm 3.16 ms ± 157 µs
Hibernate 3.67 ms ± 133 µs
jOOQ 3.79 ms ± 76 µs
Exposed DAO 8.22 ms ± 783 µs
Jimmer 8.45 ms ± 4.0 ms

Projection

Three columns across three tables into a flat DTO, 100 rows.

JDBC 729 µs ± 17 µs
Hibernate 757 µs ± 26 µs
Jimmer 758 µs ± 15 µs
Storm 786 µs ± 18 µs
jOOQ 788 µs ± 18 µs
Exposed 1.01 ms ± 17 µs
Exposed DAO 1.03 ms ± 18 µs

Batch insert

Insert 100 visits atomically and fetch the generated keys.

JDBC 2.40 ms ± 32 µs
jOOQ 2.44 ms ± 287 µs
Storm 2.72 ms ± 131 µs
Exposed 3.17 ms ± 261 µs
Hibernate 3.54 ms ± 195 µs
Jimmer 3.74 ms ± 170 µs
Exposed DAO 3.99 ms ± 451 µs

Read, modify, update

Read one owner, change one field, persist atomically. Every implementation reads a lazy association shape and writes only the changed column.

JDBC 538 µs ± 5 µs
Exposed 553 µs ± 19 µs
Hibernate 557 µs ± 9 µs
Exposed DAO 564 µs ± 17 µs
Storm 572 µs ± 13 µs
jOOQ 726 µs ± 21 µs
Jimmer 901 µs ± 22 µs

Object graph

Load the owners of a city, each with their list of pets. The one-to-many shape every application has.

JDBC 855 µs ± 43 µs
jOOQ 972 µs ± 107 µs
Storm 1.19 ms ± 59 µs
Exposed 1.33 ms ± 26 µs
Hibernate 1.33 ms ± 162 µs
Exposed DAO 1.38 ms ± 120 µs
Jimmer 1.41 ms ± 26 µs

The code being measured

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.

Entities LOC

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.

Storm 29 lines
Exposed 51 lines
Jimmer 55 lines
Exposed DAO 69 lines
Hibernate 105 lines

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.

Queries LOC

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 100 lines
Hibernatestring queries 123 lines
Exposed DAO 124 lines
Exposedhand-mapped rows 128 lines
jOOQhand-mapped rows 131 lines
Jimmer 144 lines
JDBChand-mapped rowsstring queries 257 lines

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.

The model

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.

Entities.kt Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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>

Primary key lookup

singleRowById Kotlin Show SQL
1
val visit = visits.getById(id)
generated sql
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?

Three-table join

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.

joinWithMapping Kotlin Show SQL
1
2
3
val result = pets.select()
    .where((Pet_.id greater base) and (Pet_.id lessEq base + rows))
    .resultList  // Pet, Owner and City hydrated from one query
generated sql
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 <= ?

Projection

A template picks three columns across the graph; the metamodel keeps every path compile-checked.

projection Kotlin Show SQL
1
2
3
4
5
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
generated sql
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 = ?

Batch insert

batchInsert Kotlin Show SQL
1
2
3
val ids = transaction {
    visits.insertAndFetchIds(newVisits)  // 100 visits, one atomic batch
}
generated sql
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)

Read, modify, update

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.

updateById Kotlin Show SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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))
}
generated sql
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 = ?

Object graph

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.

objectGraph Kotlin Show SQL
1
2
3
4
5
val result = pets.select()
    .where(Pet_.owner.city.id eq cityId)
    .orderBy(Pet_.owner)
    .resultGroupedBy(Pet_.owner)
    .map { (owner, pets) -> OwnerWithPets(owner, pets) }
1
2
3
4
5
6
7
8
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
    });
generated sql
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

Method and fairness

The suite is built to be argued with. Everything below is enforced in code, not prose.

  • Real round trips. One tuned PostgreSQL 17 container, reached over TCP. The raw JDBC single round trip measured about 158 µs, and every score includes it. That compresses relative differences; the mapping-heavy workloads are where library differences show.
  • JMH, properly. Two forks, five 3-second measurement iterations after warmup, single thread: latency, not throughput. Sanity checks run every workload once per trial and verify row counts before anything is timed.
  • Same work for everyone. Identical schema and data, identical transaction boundaries on writes, and update values derived from the value just read, so change-detecting libraries can never silently skip a write. On the update workload every implementation writes only the changed column and reads a lazy association shape.
  • Idiomatic code for everyone. Each library is written the way its documentation recommends: Hibernate with join fetch and @DynamicUpdate, jOOQ with generated records and MULTISET, Jimmer with fetchers, Exposed in both DSL and DAO flavors.
  • Rows are the unit of comparison. Libraries within one chart ran in the same session under the same conditions. Comparing across charts, or treating values as absolute costs, carries environment drift that comparing within a chart does not.

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.